mirror of
https://github.com/daavfx/quantum_bt_daavfx.git
synced 2026-08-07 07:37:43 +00:00
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:
Generated
+19
-91
@@ -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"
|
||||
|
||||
+11
-7
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)]),
|
||||
})
|
||||
}
|
||||
+70
-15
@@ -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<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(())
|
||||
})
|
||||
.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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,4 @@
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+97
-78
@@ -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<OHLCData[]>(() => generateOHLCData(1000));
|
||||
const [volumeData] = useState<VolumeData[]>(() => generateVolumeData(data));
|
||||
const [selectedSymbol, setSelectedSymbol] = useState('EURUSD');
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('M15');
|
||||
const [isDataLoading, setIsDataLoading] = useState(false);
|
||||
|
||||
// Generate fallback data
|
||||
const [fallbackData] = useState<OHLCData[]>(() => generateOHLCData(1000));
|
||||
const [fallbackVolumeData] = useState<VolumeData[]>(() => generateVolumeData(fallbackData));
|
||||
|
||||
// Real data from Rust backend
|
||||
const [realData, setRealData] = useState<OHLCData[]>([]);
|
||||
const [realVolumeData, setRealVolumeData] = useState<VolumeData[]>([]);
|
||||
|
||||
// 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 = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded transition-colors">
|
||||
<span className="text-sm font-semibold text-white">EUR/USD</span>
|
||||
<span className="text-sm font-semibold text-white">{selectedSymbol}</span>
|
||||
<ChevronDown size={14} className="text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded overflow-hidden">
|
||||
{['M1','M5','M15','H1','H4','D1'].map(tf => (
|
||||
<button key={tf} className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${tf === 'M15' ? 'text-white bg-zinc-800' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'}`}>{tf}</button>
|
||||
<button
|
||||
key={tf}
|
||||
onClick={() => setSelectedTimeframe(tf)}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${tf === selectedTimeframe ? 'text-white bg-zinc-800' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'}`}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadDataFromBackend}
|
||||
disabled={isDataLoading}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-zinc-400 hover:text-white bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={isDataLoading ? 'animate-spin' : ''} />
|
||||
{isDataLoading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded overflow-hidden">
|
||||
<button onClick={() => setChartType('Candle')} className={`px-2 py-1.5 transition-colors ${chartType === 'Candle' ? 'text-white bg-zinc-800' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'}`} title="Candles"><CandlestickChart size={14} /></button>
|
||||
<button onClick={() => setChartType('Bar')} className={`px-2 py-1.5 transition-colors ${chartType === 'Bar' ? 'text-white bg-zinc-800' : 'text-zinc-400 hover:text-white hover:bg-zinc-800'}`} title="Bars"><BarChart2 size={14} /></button>
|
||||
@@ -595,6 +607,13 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReplayControls
|
||||
symbol={selectedSymbol}
|
||||
timeframe={selectedTimeframe}
|
||||
isActive={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="w-72 border-l border-zinc-800/50 flex flex-col bg-zinc-950/30">
|
||||
|
||||
@@ -1,55 +1,8 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, Cpu, Database, Play, BarChart, CheckCircle2 } from 'lucide-react';
|
||||
import { MLConfig } from '../types';
|
||||
import React from 'react';
|
||||
import { Brain, Construction } from 'lucide-react';
|
||||
|
||||
const MachineLearningPanel: React.FC = () => {
|
||||
const [config, setConfig] = useState<MLConfig>({
|
||||
modelType: 'LSTM',
|
||||
features: ['RSI', 'MACD', 'Close'],
|
||||
epochs: 50,
|
||||
learningRate: 0.001,
|
||||
status: 'Idle',
|
||||
accuracy: 0
|
||||
});
|
||||
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const toggleFeature = (feature: string) => {
|
||||
if (config.features.includes(feature)) {
|
||||
setConfig({ ...config, features: config.features.filter(f => f !== feature) });
|
||||
} else {
|
||||
setConfig({ ...config, features: [...config.features, feature] });
|
||||
}
|
||||
};
|
||||
|
||||
const startTraining = () => {
|
||||
setConfig({ ...config, status: 'Training', accuracy: 0 });
|
||||
setLogs(['Initializing TensorFlow backend...', 'Normalizing dataset...', `Building ${config.modelType} architecture...`]);
|
||||
setProgress(0);
|
||||
|
||||
let epoch = 0;
|
||||
const interval = setInterval(() => {
|
||||
epoch++;
|
||||
const currentProgress = (epoch / config.epochs) * 100;
|
||||
setProgress(currentProgress);
|
||||
|
||||
// Log updates
|
||||
if (epoch % 10 === 0) {
|
||||
const loss = (Math.random() * 0.5).toFixed(4);
|
||||
const acc = (0.5 + (epoch/config.epochs) * 0.35).toFixed(4);
|
||||
setLogs(prev => [`Epoch ${epoch}/${config.epochs}: loss=${loss} - accuracy=${acc}`, ...prev]);
|
||||
}
|
||||
|
||||
if (epoch >= config.epochs) {
|
||||
clearInterval(interval);
|
||||
setConfig({ ...config, status: 'Ready', accuracy: 0.85 + Math.random() * 0.1 });
|
||||
setLogs(prev => ['Model successfully trained and saved.', ...prev]);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-900/30 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -60,144 +13,25 @@ const MachineLearningPanel: React.FC = () => {
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500">Train Deep Learning models on historical price action.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{config.status === 'Ready' && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 text-xs font-medium">
|
||||
<CheckCircle2 size={14} /> Model Ready ({ (config.accuracy * 100).toFixed(1) }%)
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={startTraining}
|
||||
disabled={config.status === 'Training'}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 font-medium transition-all ${
|
||||
config.status === 'Training'
|
||||
? 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-blue-700 hover:bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
{config.status === 'Training' ? <Cpu className="animate-pulse" size={18} /> : <Play size={18} fill="currentColor" />}
|
||||
{config.status === 'Training' ? 'Training Model...' : 'Train Model'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-full min-h-0">
|
||||
{/* Configuration Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Model Architecture */}
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-3 flex items-center gap-2">
|
||||
<Cpu size={14} /> Architecture
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1.5 block">Model Type</label>
|
||||
<select
|
||||
value={config.modelType}
|
||||
onChange={(e) => setConfig({ ...config, modelType: e.target.value as any })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-sm text-white focus:outline-none"
|
||||
>
|
||||
<option value="LSTM">Long Short-Term Memory (LSTM)</option>
|
||||
<option value="Transformer">Transformer (Attention)</option>
|
||||
<option value="RandomForest">Random Forest Regressor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1.5 block">Epochs</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.epochs}
|
||||
onChange={(e) => setConfig({ ...config, epochs: Number(e.target.value) })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400 mb-1.5 block">Learning Rate</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.learningRate}
|
||||
step="0.001"
|
||||
onChange={(e) => setConfig({ ...config, learningRate: Number(e.target.value) })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded p-2 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Engineering */}
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4 flex-1">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-3 flex items-center gap-2">
|
||||
<Database size={14} /> Feature Selection
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Open', 'High', 'Low', 'Close', 'Volume', 'RSI', 'MACD', 'EMA 20', 'EMA 50', 'ATR', 'Bollinger', 'Stoch'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => toggleFeature(f)}
|
||||
className={`px-3 py-2 rounded text-xs text-left transition-colors ${
|
||||
config.features.includes(f)
|
||||
? 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
|
||||
: 'bg-zinc-800/50 text-zinc-400 border border-transparent hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Visualization (Placeholder for Graph) */}
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4 flex flex-col relative overflow-hidden">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-3 flex items-center gap-2">
|
||||
<BarChart size={14} /> Loss / Accuracy
|
||||
</h3>
|
||||
|
||||
{config.status === 'Idle' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-zinc-600">
|
||||
<Brain size={48} className="mb-4 opacity-20" />
|
||||
<p>Configure model and start training</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col justify-end relative">
|
||||
{/* Simple CSS Bar Graph Simulation */}
|
||||
<div className="flex items-end gap-1 h-64 w-full">
|
||||
{Array.from({ length: 40 }).map((_, i) => {
|
||||
const h = config.status === 'Ready'
|
||||
? 30 + Math.random() * 60
|
||||
: (progress > (i*2.5) ? 10 + Math.random() * 50 : 5);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-blue-500/50 rounded-t-sm transition-all duration-500"
|
||||
style={{ height: `${h}%`, opacity: (i/40) }}
|
||||
></div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{config.status === 'Training' && (
|
||||
<div className="text-4xl font-mono font-bold text-white/10">{Math.round(progress)}%</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Console / Logs */}
|
||||
<div className="bg-zinc-950 border border-zinc-800 rounded-xl p-4 flex flex-col font-mono text-xs">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase mb-3">System Logs</h3>
|
||||
<div className="flex-1 overflow-y-auto space-y-1 text-zinc-400">
|
||||
{logs.length === 0 && <span className="text-zinc-600 italic">System ready.</span>}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="border-l-2 border-zinc-800 pl-2 py-0.5">
|
||||
<span className="text-zinc-600 mr-2">[{new Date().toLocaleTimeString()}]</span>
|
||||
<span className={log.includes('successfully') ? 'text-emerald-400' : ''}>{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-zinc-500">
|
||||
<Construction size={64} className="mb-4 text-zinc-600" />
|
||||
<h3 className="text-lg font-semibold text-zinc-400 mb-2">Machine Learning Module</h3>
|
||||
<p className="text-sm text-center max-w-md">
|
||||
This module is not yet implemented. ML backend integration with TensorFlow/PyTorch
|
||||
will be added in a future update.
|
||||
</p>
|
||||
<div className="mt-6 p-4 bg-zinc-800/50 rounded-lg border border-zinc-700">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Planned features:
|
||||
</p>
|
||||
<ul className="text-xs text-zinc-400 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>LSTM/Transformer model training</li>
|
||||
<li>Feature engineering pipeline</li>
|
||||
<li>Model validation and backtesting</li>
|
||||
<li>Export trained models</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Sliders, Play, RotateCw, TrendingUp, Grid, List, Activity } from 'lucide-react';
|
||||
import { OptimizationParam, OptimizationResult } from '../types';
|
||||
import { Sliders, Play, Construction } from 'lucide-react';
|
||||
import { OptimizationParam } from '../types';
|
||||
|
||||
const OptimizationPanel: React.FC = () => {
|
||||
const [viewMode, setViewMode] = useState<'table' | 'graph'>('table');
|
||||
const [params, setParams] = useState<OptimizationParam[]>([
|
||||
{ id: '1', name: 'RSI Period', min: 7, max: 21, step: 1, current: 14, enabled: true },
|
||||
{ id: '2', name: 'Stop Loss', min: 10, max: 50, step: 5, current: 20, enabled: true },
|
||||
@@ -12,88 +11,10 @@ const OptimizationPanel: React.FC = () => {
|
||||
{ id: '4', name: 'MA Filter', min: 20, max: 200, step: 20, current: 50, enabled: false },
|
||||
]);
|
||||
|
||||
const [results, setResults] = useState<OptimizationResult[]>([]);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const toggleParam = (id: string) => {
|
||||
setParams(params.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p));
|
||||
};
|
||||
|
||||
const runOptimization = () => {
|
||||
setIsOptimizing(true);
|
||||
setResults([]);
|
||||
setProgress(0);
|
||||
|
||||
// Simulate Genetic Algorithm process
|
||||
let step = 0;
|
||||
const totalSteps = 20;
|
||||
const interval = setInterval(() => {
|
||||
step++;
|
||||
setProgress((step / totalSteps) * 100);
|
||||
|
||||
// Generate mock result
|
||||
const profit = Math.floor(Math.random() * 5000) - 1000;
|
||||
const dd = Math.floor(Math.random() * 15);
|
||||
const score = (profit / (dd + 1)).toFixed(2);
|
||||
|
||||
const newResult: OptimizationResult = {
|
||||
pass: step,
|
||||
params: `RSI=${7 + Math.floor(Math.random()*14)}, SL=${10 + Math.floor(Math.random()*40)}`,
|
||||
profit: profit,
|
||||
drawdown: dd,
|
||||
score: Number(score)
|
||||
};
|
||||
|
||||
setResults(prev => [newResult, ...prev].sort((a, b) => b.score - a.score));
|
||||
|
||||
if (step >= totalSteps) {
|
||||
clearInterval(interval);
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Generate Mock Heatmap Data (2D array)
|
||||
// 10x10 grid simulation
|
||||
const renderHeatmap = () => {
|
||||
const gridSize = 12;
|
||||
const cells = [];
|
||||
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
for (let j = 0; j < gridSize; j++) {
|
||||
// Simulate fitness landscape
|
||||
// Higher values in the center
|
||||
const cx = 6, cy = 6;
|
||||
const dist = Math.sqrt(Math.pow(i - cx, 2) + Math.pow(j - cy, 2));
|
||||
const intensity = Math.max(0.1, 1 - (dist / 8));
|
||||
const noise = (Math.random() - 0.5) * 0.2;
|
||||
const value = Math.max(0, intensity + noise);
|
||||
|
||||
// Color mapping: Green (High), Yellow (Med), Red (Low)
|
||||
let color;
|
||||
if (value > 0.7) color = `rgba(34, 197, 94, ${value})`; // Green
|
||||
else if (value > 0.4) color = `rgba(234, 179, 8, ${value})`; // Yellow
|
||||
else color = `rgba(239, 68, 68, ${value + 0.2})`; // Red
|
||||
|
||||
cells.push(
|
||||
<div
|
||||
key={`${i}-${j}`}
|
||||
className="rounded-sm hover:scale-110 transition-transform duration-200 cursor-pointer relative group border border-black/20"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block bg-zinc-900 text-white text-[10px] px-2 py-1 rounded border border-zinc-700 whitespace-nowrap z-50">
|
||||
Profit: ${(value * 5000).toFixed(0)}<br/>
|
||||
Param A: {i * 2 + 10}<br/>
|
||||
Param B: {j * 5 + 20}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-900/30 p-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -105,16 +26,11 @@ const OptimizationPanel: React.FC = () => {
|
||||
<p className="text-sm text-zinc-500">Find robust parameters using evolutionary algorithms.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={runOptimization}
|
||||
disabled={isOptimizing}
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 font-medium transition-all ${
|
||||
isOptimizing
|
||||
? 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
}`}
|
||||
disabled
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 font-medium bg-zinc-800 text-zinc-500 cursor-not-allowed"
|
||||
>
|
||||
{isOptimizing ? <RotateCw className="animate-spin" size={18} /> : <Play size={18} fill="currentColor" />}
|
||||
{isOptimizing ? 'Optimizing...' : 'Start Optimization'}
|
||||
<Construction size={18} />
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -158,91 +74,24 @@ const OptimizationPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Panel */}
|
||||
<div className="flex-1 bg-zinc-900/50 border border-zinc-800 rounded-xl flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b border-zinc-800 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-xs font-semibold text-zinc-500 uppercase">Optimization Results</h3>
|
||||
<div className="flex items-center bg-zinc-900 border border-zinc-800 rounded p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`p-1.5 rounded transition-colors ${viewMode === 'table' ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
title="Table View"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('graph')}
|
||||
className={`p-1.5 rounded transition-colors ${viewMode === 'graph' ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
title="Graph View"
|
||||
>
|
||||
<Grid size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOptimizing && (
|
||||
<div className="w-48 h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{viewMode === 'table' ? (
|
||||
<div className="absolute inset-0 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-500 uppercase font-medium sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Pass</th>
|
||||
<th className="px-4 py-3">Parameters</th>
|
||||
<th className="px-4 py-3 text-right">Profit</th>
|
||||
<th className="px-4 py-3 text-right">Drawdown</th>
|
||||
<th className="px-4 py-3 text-right">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{results.length === 0 && !isOptimizing && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-zinc-500 italic">
|
||||
Start optimization to see results...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{results.map((r, i) => (
|
||||
<tr key={i} className={`hover:bg-zinc-800/30 transition-colors ${i === 0 ? 'bg-emerald-500/5' : ''}`}>
|
||||
<td className="px-4 py-2 font-mono text-zinc-500">#{r.pass}</td>
|
||||
<td className="px-4 py-2 text-zinc-300">{r.params}</td>
|
||||
<td className={`px-4 py-2 text-right font-mono font-medium ${r.profit > 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
${r.profit.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-red-300">{r.drawdown}%</td>
|
||||
<td className="px-4 py-2 text-right font-bold text-blue-400">{r.score}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col p-6">
|
||||
{/* Graph View Simulation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-xs text-zinc-400">
|
||||
X: <span className="text-white">RSI Period (7-21)</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
Y: <span className="text-white">Stop Loss (10-50)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-12 grid-rows-12 gap-1 bg-zinc-900/50 p-1 border border-zinc-800 rounded-lg">
|
||||
{renderHeatmap()}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-4 mt-4 text-[10px] text-zinc-500 uppercase font-medium">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-red-500/80 rounded-sm"></div> Low Profit</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-yellow-500/80 rounded-sm"></div> Med Profit</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-emerald-500/80 rounded-sm"></div> High Profit</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Placeholder for Results */}
|
||||
<div className="flex-1 bg-zinc-900/50 border border-zinc-800 rounded-xl flex flex-col items-center justify-center text-zinc-500">
|
||||
<Construction size={64} className="mb-4 text-zinc-600" />
|
||||
<h3 className="text-lg font-semibold text-zinc-400 mb-2">Optimization Engine</h3>
|
||||
<p className="text-sm text-center max-w-md">
|
||||
The genetic algorithm optimization module is not yet implemented.
|
||||
This will allow you to test multiple parameter combinations to find the most robust settings.
|
||||
</p>
|
||||
<div className="mt-6 p-4 bg-zinc-800/50 rounded-lg border border-zinc-700">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Planned features:
|
||||
</p>
|
||||
<ul className="text-xs text-zinc-400 mt-2 space-y-1 list-disc list-inside">
|
||||
<li>Walk-forward optimization</li>
|
||||
<li>Multi-parameter grid search</li>
|
||||
<li>Fitness landscape visualization</li>
|
||||
<li>Robustness testing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { ReplayInfo } from '../services/replayService';
|
||||
|
||||
interface ReplayControlsProps {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
isActive: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ReplayControls: React.FC<ReplayControlsProps> = ({
|
||||
symbol,
|
||||
timeframe,
|
||||
isActive,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [totalCandles, setTotalCandles] = useState(0);
|
||||
const [speed, setSpeed] = useState(1.0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
const [replayState, setReplayState] = useState<{
|
||||
loadReplaySession: (symbol: string, timeframe: string) => Promise<ReplayInfo>;
|
||||
startReplay: () => Promise<void>;
|
||||
pauseReplay: () => Promise<void>;
|
||||
stopReplay: () => Promise<void>;
|
||||
stepForward: (steps?: number) => Promise<{currentIndex: number; progress: number} | null>;
|
||||
stepBackward: (steps?: number) => Promise<{currentIndex: number; progress: number} | null>;
|
||||
setReplaySpeed: (speed: number) => Promise<void>;
|
||||
seekToIndex: (index: number) => Promise<{currentIndex: number; progress: number} | null>;
|
||||
advanceReplay: (deltaTime: number) => Promise<{currentIndex: number; progress: number} | null>;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadReplayService = async () => {
|
||||
try {
|
||||
const { loadReplaySession, startReplay, pauseReplay, stopReplay, stepForward, stepBackward, setReplaySpeed, seekToIndex, advanceReplay } = await import('../services/replayService');
|
||||
setReplayState({ loadReplaySession, startReplay, pauseReplay, stopReplay, stepForward, stepBackward, setReplaySpeed, seekToIndex, advanceReplay });
|
||||
} catch (err) {
|
||||
console.error('Failed to load replay service:', err);
|
||||
}
|
||||
};
|
||||
loadReplayService();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && symbol && timeframe && replayState) {
|
||||
replayState.loadReplaySession(symbol, timeframe).then(info => {
|
||||
setTotalCandles(info.totalCandles);
|
||||
setCurrentIndex(0);
|
||||
setIsLoaded(true);
|
||||
setProgress(0);
|
||||
}).catch(err => {
|
||||
console.error('Failed to load replay:', err);
|
||||
});
|
||||
}
|
||||
}, [isActive, symbol, timeframe, replayState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !isActive || !replayState) return;
|
||||
|
||||
let animationId: number;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const deltaTime = currentTime - lastTime;
|
||||
lastTime = currentTime;
|
||||
|
||||
replayState.advanceReplay(deltaTime).then(update => {
|
||||
if (update) {
|
||||
setCurrentIndex(update.currentIndex);
|
||||
setProgress(update.progress);
|
||||
|
||||
if (update.currentIndex >= totalCandles - 1) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, [isPlaying, isActive, totalCandles, replayState]);
|
||||
|
||||
const handlePlay = useCallback(async () => {
|
||||
if (!replayState) return;
|
||||
if (isPlaying) {
|
||||
await replayState.pauseReplay();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
await replayState.startReplay();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [isPlaying, replayState]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!replayState) return;
|
||||
await replayState.stopReplay();
|
||||
setIsPlaying(false);
|
||||
setCurrentIndex(0);
|
||||
setProgress(0);
|
||||
}, [replayState]);
|
||||
|
||||
const handleStepForward = useCallback(async () => {
|
||||
if (!replayState) return;
|
||||
const update = await replayState.stepForward(1);
|
||||
if (update) {
|
||||
setCurrentIndex(update.currentIndex);
|
||||
setProgress(update.progress);
|
||||
}
|
||||
}, [replayState]);
|
||||
|
||||
const handleStepBackward = useCallback(async () => {
|
||||
if (!replayState) return;
|
||||
const update = await replayState.stepBackward(1);
|
||||
if (update) {
|
||||
setCurrentIndex(update.currentIndex);
|
||||
setProgress(update.progress);
|
||||
}
|
||||
}, [replayState]);
|
||||
|
||||
const handleSpeedChange = useCallback(async (newSpeed: number) => {
|
||||
if (!replayState) return;
|
||||
await replayState.setReplaySpeed(newSpeed);
|
||||
setSpeed(newSpeed);
|
||||
}, [replayState]);
|
||||
|
||||
const handleSeek = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!replayState) return;
|
||||
const index = parseInt(e.target.value);
|
||||
const update = await replayState.seekToIndex(index);
|
||||
if (update) {
|
||||
setCurrentIndex(update.currentIndex);
|
||||
setProgress(update.progress);
|
||||
}
|
||||
}, [replayState]);
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div className="bg-zinc-900/95 backdrop-blur-sm border border-zinc-700 rounded-lg shadow-2xl px-6 py-4 flex flex-col gap-3 min-w-[600px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-sm font-medium text-zinc-200">Market Replay</span>
|
||||
<span className="text-xs text-zinc-500 ml-2">{symbol} {timeframe}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500 w-12">{currentIndex}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={totalCandles - 1}
|
||||
value={currentIndex}
|
||||
onChange={handleSeek}
|
||||
className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs text-zinc-500 w-12 text-right">{totalCandles}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleStepBackward}
|
||||
disabled={currentIndex <= 0}
|
||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Step Backward"
|
||||
>
|
||||
<svg className="w-4 h-4 text-zinc-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
disabled={!isLoaded}
|
||||
className="p-3 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleStepForward}
|
||||
disabled={currentIndex >= totalCandles - 1}
|
||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Step Forward"
|
||||
>
|
||||
<svg className="w-4 h-4 text-zinc-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
|
||||
title="Stop"
|
||||
>
|
||||
<svg className="w-4 h-4 text-zinc-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">Speed:</span>
|
||||
{[0.5, 1, 2, 5, 10].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleSpeedChange(s)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
speed === s
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
{Math.round(progress * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplayControls;
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Data Service - Bridges React frontend to Rust backend via Tauri
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface OHLCVData {
|
||||
time: number;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
candlesImported: number;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
}
|
||||
|
||||
export interface SessionConfig {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
maxCacheSize: number;
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
entries: number;
|
||||
totalCandles: number;
|
||||
}
|
||||
|
||||
export async function loadOHLCVData(symbol: string, timeframe: string): Promise<ChartData[]> {
|
||||
try {
|
||||
const data = await invoke<OHLCVData[]>('load_ohlcv_data', {
|
||||
symbol,
|
||||
timeframe
|
||||
});
|
||||
|
||||
return data.map(c => ({
|
||||
time: c.time,
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close),
|
||||
volume: parseFloat(c.volume),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load OHLCV data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDataWindow(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
startIdx: number,
|
||||
endIdx: number
|
||||
): Promise<ChartData[]> {
|
||||
try {
|
||||
const data = await invoke<OHLCVData[]>('get_data_window', {
|
||||
symbol,
|
||||
timeframe,
|
||||
startIdx,
|
||||
endIdx
|
||||
});
|
||||
|
||||
return data.map(c => ({
|
||||
time: c.time,
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close),
|
||||
volume: parseFloat(c.volume),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get data window:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function aggregateData(
|
||||
symbol: string,
|
||||
sourceTimeframe: string,
|
||||
targetTimeframe: string
|
||||
): Promise<ChartData[]> {
|
||||
try {
|
||||
const data = await invoke<OHLCVData[]>('aggregate_data', {
|
||||
symbol,
|
||||
sourceTimeframe,
|
||||
targetTimeframe
|
||||
});
|
||||
|
||||
return data.map(c => ({
|
||||
time: c.time,
|
||||
open: parseFloat(c.open),
|
||||
high: parseFloat(c.high),
|
||||
low: parseFloat(c.low),
|
||||
close: parseFloat(c.close),
|
||||
volume: parseFloat(c.volume),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to aggregate data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function importCSVData(
|
||||
filePath: string,
|
||||
symbol: string,
|
||||
timeframe: string
|
||||
): Promise<ImportResult | null> {
|
||||
try {
|
||||
const result = await invoke<ImportResult>('import_csv_data', {
|
||||
filePath,
|
||||
symbol,
|
||||
timeframe
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to import CSV:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableSymbols(): Promise<string[]> {
|
||||
try {
|
||||
return await invoke<string[]>('get_available_symbols');
|
||||
} catch (error) {
|
||||
console.error('Failed to get symbols:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionConfig(): Promise<SessionConfig | null> {
|
||||
try {
|
||||
return await invoke<SessionConfig>('get_session_config');
|
||||
} catch (error) {
|
||||
console.error('Failed to get session config:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionConfig(config: SessionConfig): Promise<boolean> {
|
||||
try {
|
||||
await invoke('update_session_config', { config });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update session config:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCacheStats(): Promise<CacheStats | null> {
|
||||
try {
|
||||
return await invoke<CacheStats>('get_cache_stats');
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache stats:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCache(): Promise<boolean> {
|
||||
try {
|
||||
await invoke('clear_cache');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDrawings(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
chartType: string,
|
||||
drawings: DrawingData[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await invoke('save_drawings', {
|
||||
symbol,
|
||||
timeframe,
|
||||
chartType,
|
||||
drawings
|
||||
});
|
||||
console.log(`Saved ${drawings.length} drawings for ${symbol} ${timeframe}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save drawings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDrawings(
|
||||
symbol: string,
|
||||
timeframe: string
|
||||
): Promise<DrawingData[]> {
|
||||
try {
|
||||
const drawings = await invoke<DrawingData[]>('load_drawings', { symbol, timeframe });
|
||||
console.log(`Loaded ${drawings.length} drawings for ${symbol} ${timeframe}`);
|
||||
return drawings;
|
||||
} catch (error) {
|
||||
console.error('Failed to load drawings:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDrawings(
|
||||
symbol: string,
|
||||
timeframe: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await invoke('delete_drawings', { symbol, timeframe });
|
||||
console.log(`Deleted drawings for ${symbol} ${timeframe}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete drawings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DrawingData {
|
||||
id: string;
|
||||
drawing_type: 'trendline' | 'hline' | 'vline' | 'rectangle';
|
||||
t1: number;
|
||||
p1: number;
|
||||
t2?: number;
|
||||
p2?: number;
|
||||
color?: string;
|
||||
line_width?: number;
|
||||
}
|
||||
|
||||
import type { IndicatorConfig, IndicatorType } from '../types/indicators';
|
||||
|
||||
export async function saveIndicators(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
indicators: IndicatorConfig[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await invoke('save_indicators', {
|
||||
symbol,
|
||||
timeframe,
|
||||
indicators: indicators.map(ind => ({
|
||||
id: ind.id,
|
||||
type: ind.type,
|
||||
name: ind.name,
|
||||
visible: ind.visible,
|
||||
parameters: JSON.stringify(ind.parameters),
|
||||
style: JSON.stringify(ind.style)
|
||||
}))
|
||||
});
|
||||
console.log(`Saved ${indicators.length} indicators for ${symbol} ${timeframe}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save indicators:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadIndicators(
|
||||
symbol: string,
|
||||
timeframe: string
|
||||
): Promise<IndicatorConfig[]> {
|
||||
try {
|
||||
const data = await invoke<Array<{
|
||||
id: string;
|
||||
type: IndicatorType;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
parameters: string;
|
||||
style: string;
|
||||
}>>('load_indicators', { symbol, timeframe });
|
||||
|
||||
const indicators: IndicatorConfig[] = data.map(row => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
visible: row.visible,
|
||||
parameters: JSON.parse(row.parameters),
|
||||
style: JSON.parse(row.style)
|
||||
}));
|
||||
|
||||
console.log(`Loaded ${indicators.length} indicators for ${symbol} ${timeframe}`);
|
||||
return indicators;
|
||||
} catch (error) {
|
||||
console.error('Failed to load indicators:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteIndicators(
|
||||
symbol: string,
|
||||
timeframe: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await invoke('delete_indicators', { symbol, timeframe });
|
||||
console.log(`Deleted indicators for ${symbol} ${timeframe}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete indicators:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Indicator Service
|
||||
* Manages Web Workers for background indicator calculations
|
||||
* Handles worker pool, message passing, and caching
|
||||
*/
|
||||
|
||||
import type {
|
||||
IndicatorConfig,
|
||||
IndicatorResult,
|
||||
IndicatorType,
|
||||
OhlcvData,
|
||||
CalculateMessage
|
||||
} from '../types/indicators';
|
||||
|
||||
const MAX_WORKERS = 4;
|
||||
const WORKER_IDLE_TIMEOUT = 30000;
|
||||
|
||||
interface WorkerTask {
|
||||
id: string;
|
||||
config: IndicatorConfig;
|
||||
ohlcv: OhlcvData[];
|
||||
resolve: (result: IndicatorResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PooledWorker {
|
||||
worker: Worker;
|
||||
busy: boolean;
|
||||
lastUsed: number;
|
||||
currentTaskId: string | null;
|
||||
}
|
||||
|
||||
class IndicatorService {
|
||||
private workers: PooledWorker[] = [];
|
||||
private taskQueue: WorkerTask[] = [];
|
||||
private activeTasks: Map<string, WorkerTask> = new Map();
|
||||
private resultsCache: Map<string, IndicatorResult> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
if (this.workers.length === 0) {
|
||||
for (let i = 0; i < MAX_WORKERS; i++) {
|
||||
this.createWorker();
|
||||
}
|
||||
console.log(`[IndicatorService] Initialized with ${MAX_WORKERS} workers`);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateIndicator(
|
||||
config: IndicatorConfig,
|
||||
ohlcv: OhlcvData[]
|
||||
): Promise<IndicatorResult> {
|
||||
this.initialize();
|
||||
|
||||
const cacheKey = this.getCacheKey(config, ohlcv);
|
||||
const cached = this.resultsCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[IndicatorService] Cache hit for ${config.type} (${config.id})`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const task: WorkerTask = {
|
||||
id: config.id,
|
||||
config,
|
||||
ohlcv,
|
||||
resolve: (result) => {
|
||||
this.resultsCache.set(cacheKey, result);
|
||||
this.cleanupCache();
|
||||
resolve(result);
|
||||
},
|
||||
reject,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const existingTask = this.activeTasks.get(config.id);
|
||||
if (existingTask) {
|
||||
existingTask.reject(new Error('Superseded by new calculation'));
|
||||
}
|
||||
|
||||
this.activeTasks.set(config.id, task);
|
||||
this.taskQueue.push(task);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
async calculateMultipleIndicators(
|
||||
configs: IndicatorConfig[],
|
||||
ohlcv: OhlcvData[]
|
||||
): Promise<IndicatorResult[]> {
|
||||
const promises = configs
|
||||
.filter(config => config.visible)
|
||||
.map(config => this.calculateIndicator(config, ohlcv));
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
cancelCalculation(indicatorId: string): void {
|
||||
const task = this.activeTasks.get(indicatorId);
|
||||
if (task) {
|
||||
task.reject(new Error('Calculation cancelled'));
|
||||
this.activeTasks.delete(indicatorId);
|
||||
|
||||
const queueIndex = this.taskQueue.findIndex(t => t.id === indicatorId);
|
||||
if (queueIndex > -1) {
|
||||
this.taskQueue.splice(queueIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
this.taskQueue.forEach(task => {
|
||||
task.reject(new Error('All calculations cancelled'));
|
||||
});
|
||||
this.taskQueue = [];
|
||||
|
||||
this.activeTasks.forEach((task, id) => {
|
||||
task.reject(new Error('All calculations cancelled'));
|
||||
});
|
||||
this.activeTasks.clear();
|
||||
|
||||
this.workers.forEach(pw => pw.worker.terminate());
|
||||
this.workers = [];
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.resultsCache.clear();
|
||||
}
|
||||
|
||||
getCacheStats(): { size: number; keys: string[] } {
|
||||
return {
|
||||
size: this.resultsCache.size,
|
||||
keys: Array.from(this.resultsCache.keys())
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.cancelAll();
|
||||
this.workers.forEach(pw => pw.worker.terminate());
|
||||
this.workers = [];
|
||||
}
|
||||
|
||||
private createWorker(): PooledWorker {
|
||||
const worker = new Worker(
|
||||
new URL('../workers/indicator.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
|
||||
const pooledWorker: PooledWorker = {
|
||||
worker,
|
||||
busy: false,
|
||||
lastUsed: Date.now(),
|
||||
currentTaskId: null
|
||||
};
|
||||
|
||||
worker.onmessage = (event) => {
|
||||
this.handleWorkerMessage(pooledWorker, event.data);
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
console.error('[IndicatorService] Worker error:', error);
|
||||
this.handleWorkerError(pooledWorker, error);
|
||||
};
|
||||
|
||||
this.workers.push(pooledWorker);
|
||||
return pooledWorker;
|
||||
}
|
||||
|
||||
private handleWorkerMessage(
|
||||
pooledWorker: PooledWorker,
|
||||
data: { type: string; payload: unknown }
|
||||
): void {
|
||||
const { type, payload } = data;
|
||||
|
||||
if (type === 'result') {
|
||||
const result = payload as IndicatorResult;
|
||||
const task = this.activeTasks.get(result.id);
|
||||
|
||||
if (task) {
|
||||
task.resolve(result);
|
||||
this.activeTasks.delete(result.id);
|
||||
}
|
||||
|
||||
pooledWorker.busy = false;
|
||||
pooledWorker.currentTaskId = null;
|
||||
pooledWorker.lastUsed = Date.now();
|
||||
|
||||
this.processQueue();
|
||||
} else if (type === 'error') {
|
||||
const { id, error } = payload as { id: string; error: string };
|
||||
const task = this.activeTasks.get(id);
|
||||
|
||||
if (task) {
|
||||
task.reject(new Error(error));
|
||||
this.activeTasks.delete(id);
|
||||
}
|
||||
|
||||
pooledWorker.busy = false;
|
||||
pooledWorker.currentTaskId = null;
|
||||
pooledWorker.lastUsed = Date.now();
|
||||
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private handleWorkerError(pooledWorker: PooledWorker, error: ErrorEvent): void {
|
||||
console.error('[IndicatorService] Worker error:', error);
|
||||
|
||||
if (pooledWorker.currentTaskId) {
|
||||
const task = this.activeTasks.get(pooledWorker.currentTaskId);
|
||||
if (task) {
|
||||
task.reject(new Error(error.message));
|
||||
this.activeTasks.delete(pooledWorker.currentTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
const index = this.workers.indexOf(pooledWorker);
|
||||
if (index > -1) {
|
||||
pooledWorker.worker.terminate();
|
||||
this.workers[index] = this.createWorker();
|
||||
}
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
if (this.taskQueue.length === 0) return;
|
||||
|
||||
const availableWorker = this.workers.find(w => !w.busy);
|
||||
if (!availableWorker) return;
|
||||
|
||||
const task = this.taskQueue.shift();
|
||||
if (!task) return;
|
||||
|
||||
if (!this.activeTasks.has(task.id)) return;
|
||||
|
||||
availableWorker.busy = true;
|
||||
availableWorker.currentTaskId = task.id;
|
||||
availableWorker.lastUsed = Date.now();
|
||||
|
||||
const message: CalculateMessage = {
|
||||
type: 'calculate',
|
||||
payload: {
|
||||
id: task.config.id,
|
||||
indicatorType: task.config.type,
|
||||
ohlcv: task.ohlcv,
|
||||
parameters: task.config.parameters
|
||||
}
|
||||
};
|
||||
|
||||
availableWorker.worker.postMessage(message);
|
||||
}
|
||||
|
||||
private getCacheKey(config: IndicatorConfig, ohlcv: OhlcvData[]): string {
|
||||
const dataHash = this.hashOhlcv(ohlcv);
|
||||
const paramsHash = JSON.stringify(config.parameters);
|
||||
return `${config.type}_${config.id}_${paramsHash}_${dataHash}`;
|
||||
}
|
||||
|
||||
private hashOhlcv(ohlcv: OhlcvData[]): string {
|
||||
if (ohlcv.length === 0) return 'empty';
|
||||
const first = ohlcv[0].time;
|
||||
const last = ohlcv[ohlcv.length - 1].time;
|
||||
const count = ohlcv.length;
|
||||
return `${first}_${last}_${count}`;
|
||||
}
|
||||
|
||||
private cleanupCache(): void {
|
||||
if (this.resultsCache.size > 50) {
|
||||
const entries = Array.from(this.resultsCache.entries());
|
||||
entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
|
||||
this.resultsCache = new Map(entries.slice(0, 40));
|
||||
}
|
||||
}
|
||||
|
||||
private startCleanupInterval(): void {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
this.workers = this.workers.filter(pw => {
|
||||
if (!pw.busy && now - pw.lastUsed > WORKER_IDLE_TIMEOUT) {
|
||||
pw.worker.terminate();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
while (this.workers.length < MAX_WORKERS) {
|
||||
this.createWorker();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
export const indicatorService = new IndicatorService();
|
||||
export default indicatorService;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Replay Service - Frontend service for market replay functionality
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface ReplayInfo {
|
||||
totalCandles: number;
|
||||
currentIndex: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface ReplayStateResponse {
|
||||
isLoaded: boolean;
|
||||
isPlaying: boolean;
|
||||
currentIndex: number;
|
||||
totalCandles: number;
|
||||
speed: number;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ReplayUpdate {
|
||||
currentIndex: number;
|
||||
totalCandles: number;
|
||||
candle: CandleData;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface CandleData {
|
||||
time: number;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
}
|
||||
|
||||
export async function loadReplaySession(symbol: string, timeframe: string): Promise<ReplayInfo> {
|
||||
try {
|
||||
return await invoke<ReplayInfo>('load_replay_session', { symbol, timeframe });
|
||||
} catch (error) {
|
||||
console.error('Failed to load replay session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startReplay(): Promise<void> {
|
||||
try {
|
||||
await invoke('start_replay');
|
||||
} catch (error) {
|
||||
console.error('Failed to start replay:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseReplay(): Promise<void> {
|
||||
try {
|
||||
await invoke('pause_replay');
|
||||
} catch (error) {
|
||||
console.error('Failed to pause replay:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopReplay(): Promise<void> {
|
||||
try {
|
||||
await invoke('stop_replay');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop replay:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stepForward(steps?: number): Promise<ReplayUpdate | null> {
|
||||
try {
|
||||
return await invoke<ReplayUpdate | null>('step_forward', { steps });
|
||||
} catch (error) {
|
||||
console.error('Failed to step forward:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stepBackward(steps?: number): Promise<ReplayUpdate | null> {
|
||||
try {
|
||||
return await invoke<ReplayUpdate | null>('step_backward', { steps });
|
||||
} catch (error) {
|
||||
console.error('Failed to step backward:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setReplaySpeed(speed: number): Promise<void> {
|
||||
try {
|
||||
await invoke('set_replay_speed', { speed });
|
||||
} catch (error) {
|
||||
console.error('Failed to set replay speed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function seekToIndex(index: number): Promise<ReplayUpdate | null> {
|
||||
try {
|
||||
return await invoke<ReplayUpdate | null>('seek_to_index', { index });
|
||||
} catch (error) {
|
||||
console.error('Failed to seek:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReplayState(): Promise<ReplayStateResponse> {
|
||||
try {
|
||||
return await invoke<ReplayStateResponse>('get_replay_state');
|
||||
} catch (error) {
|
||||
console.error('Failed to get replay state:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function advanceReplay(deltaTimeMs: number): Promise<ReplayUpdate | null> {
|
||||
try {
|
||||
return await invoke<ReplayUpdate | null>('advance_replay', { deltaTimeMs });
|
||||
} catch (error) {
|
||||
console.error('Failed to advance replay:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface BacktestRequest {
|
||||
strategy: StrategyConfigRequest;
|
||||
config: BacktestConfigRequest;
|
||||
}
|
||||
|
||||
export interface StrategyConfigRequest {
|
||||
name: string;
|
||||
entry_conditions: ConditionRequest[];
|
||||
exit_conditions: ConditionRequest[];
|
||||
stop_loss_pips: number;
|
||||
take_profit_pips: number;
|
||||
lot_size: number;
|
||||
risk_percent: number;
|
||||
}
|
||||
|
||||
export interface ConditionRequest {
|
||||
indicator: string;
|
||||
operator: string;
|
||||
value: number;
|
||||
period?: number;
|
||||
}
|
||||
|
||||
export interface BacktestConfigRequest {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
start_date: number;
|
||||
end_date: number;
|
||||
initial_deposit: number;
|
||||
leverage: number;
|
||||
modeling: string;
|
||||
}
|
||||
|
||||
export interface BacktestResultResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
trades: TradeResponse[];
|
||||
equity_curve: EquityPointResponse[];
|
||||
stats: BacktestStatsResponse;
|
||||
}
|
||||
|
||||
export interface TradeResponse {
|
||||
id: string;
|
||||
time: number;
|
||||
position: string;
|
||||
entry_price: number;
|
||||
exit_price: number;
|
||||
pnl: number;
|
||||
pnl_percent: number;
|
||||
status: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface EquityPointResponse {
|
||||
time: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BacktestStatsResponse {
|
||||
total_trades: number;
|
||||
net_profit: number;
|
||||
profit_factor: number;
|
||||
win_rate: number;
|
||||
max_drawdown: number;
|
||||
max_drawdown_percent: number;
|
||||
sharpe_ratio: number;
|
||||
gross_profit: number;
|
||||
gross_loss: number;
|
||||
expected_payoff: number;
|
||||
absolute_drawdown: number;
|
||||
short_positions: number;
|
||||
short_won: number;
|
||||
long_positions: number;
|
||||
long_won: number;
|
||||
profit_trades: number;
|
||||
loss_trades: number;
|
||||
largest_profit_trade: number;
|
||||
largest_loss_trade: number;
|
||||
average_profit_trade: number;
|
||||
average_loss_trade: number;
|
||||
max_consecutive_wins: number;
|
||||
max_consecutive_losses: number;
|
||||
modeling_quality: number;
|
||||
ticks_modelled: number;
|
||||
}
|
||||
|
||||
export interface JobProgressEvent {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface JobCompleteEvent {
|
||||
jobId: string;
|
||||
result: BacktestResultResponse;
|
||||
}
|
||||
|
||||
export interface JobErrorEvent {
|
||||
jobId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
let progressListeners: Array<(e: JobProgressEvent) => void> = [];
|
||||
let completeListeners: Array<(e: JobCompleteEvent) => void> = [];
|
||||
let errorListeners: Array<(e: JobErrorEvent) => void> = [];
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenComplete: UnlistenFn | null = null;
|
||||
let unlistenError: UnlistenFn | null = null;
|
||||
|
||||
async function setupListeners() {
|
||||
if (unlistenProgress) return;
|
||||
|
||||
unlistenProgress = await listen<JobProgressEvent>('quantum://job/progress', (event) => {
|
||||
progressListeners.forEach(cb => cb(event.payload));
|
||||
});
|
||||
|
||||
unlistenComplete = await listen<JobCompleteEvent>('quantum://job/complete', (event) => {
|
||||
completeListeners.forEach(cb => cb(event.payload));
|
||||
});
|
||||
|
||||
unlistenError = await listen<JobErrorEvent>('quantum://job/error', (event) => {
|
||||
errorListeners.forEach(cb => cb(event.payload));
|
||||
});
|
||||
}
|
||||
|
||||
export async function onJobProgress(callback: (e: JobProgressEvent) => void): Promise<UnlistenFn> {
|
||||
await setupListeners();
|
||||
progressListeners.push(callback);
|
||||
return () => {
|
||||
progressListeners = progressListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
export async function onJobComplete(callback: (e: JobCompleteEvent) => void): Promise<UnlistenFn> {
|
||||
await setupListeners();
|
||||
completeListeners.push(callback);
|
||||
return () => {
|
||||
completeListeners = completeListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
export async function onJobError(callback: (e: JobErrorEvent) => void): Promise<UnlistenFn> {
|
||||
await setupListeners();
|
||||
errorListeners.push(callback);
|
||||
return () => {
|
||||
errorListeners = errorListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
function generateJobId(): string {
|
||||
return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
export async function startBacktest(request: BacktestRequest): Promise<{ jobId: string }> {
|
||||
const jobId = generateJobId();
|
||||
|
||||
const formattedRequest = {
|
||||
strategy: {
|
||||
name: request.strategy.name,
|
||||
entry_conditions: request.strategy.entry_conditions.map(c => ({
|
||||
indicator: c.indicator,
|
||||
operator: c.operator,
|
||||
value: c.value,
|
||||
period: c.period || 14,
|
||||
})),
|
||||
exit_conditions: request.strategy.exit_conditions.map(c => ({
|
||||
indicator: c.indicator,
|
||||
operator: c.operator,
|
||||
value: c.value,
|
||||
period: c.period || 14,
|
||||
})),
|
||||
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,
|
||||
},
|
||||
config: {
|
||||
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: request.config.modeling,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await invoke<BacktestResultResponse>('run_backtest', { request: formattedRequest });
|
||||
|
||||
setTimeout(() => {
|
||||
completeListeners.forEach(cb => cb({ jobId, result }));
|
||||
}, 100);
|
||||
|
||||
return { jobId };
|
||||
} catch (error) {
|
||||
throw new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOptimization(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
paramName: string,
|
||||
paramMin: number,
|
||||
paramMax: number,
|
||||
paramStep: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
return await invoke('run_optimization', {
|
||||
symbol,
|
||||
timeframe,
|
||||
paramName,
|
||||
paramMin,
|
||||
paramMax,
|
||||
paramStep,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Optimization failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMonteCarlo(
|
||||
trades: TradeResponse[],
|
||||
initialDeposit: number,
|
||||
runs: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
return await invoke('run_equity_monte_carlo', {
|
||||
trades,
|
||||
initial_deposit: initialDeposit,
|
||||
runs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Monte Carlo failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSampleData(
|
||||
symbol: string,
|
||||
startDate: number,
|
||||
endDate: number
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
return await invoke('load_sample_data', {
|
||||
symbol,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load sample data:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableSymbols(): Promise<string[]> {
|
||||
try {
|
||||
return await invoke('get_available_symbols');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableTimeframes(): Promise<string[]> {
|
||||
try {
|
||||
return await invoke('get_available_timeframes');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppVersion(): Promise<string> {
|
||||
try {
|
||||
return await invoke('get_app_version');
|
||||
} catch {
|
||||
return '0.1.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Indicator Types and Interfaces
|
||||
* Shared types for the indicator system
|
||||
*/
|
||||
|
||||
export type IndicatorType =
|
||||
| 'sma'
|
||||
| 'ema'
|
||||
| 'wma'
|
||||
| 'bollinger'
|
||||
| 'rsi'
|
||||
| 'macd'
|
||||
| 'atr'
|
||||
| 'vwap';
|
||||
|
||||
export interface IndicatorConfig {
|
||||
id: string;
|
||||
type: IndicatorType;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
parameters: Record<string, number | string>;
|
||||
style: IndicatorStyle;
|
||||
}
|
||||
|
||||
export interface IndicatorStyle {
|
||||
color?: string;
|
||||
lineWidth?: number;
|
||||
lineStyle?: 'solid' | 'dashed' | 'dotted';
|
||||
opacity?: number;
|
||||
fillColor?: string;
|
||||
fillOpacity?: number;
|
||||
}
|
||||
|
||||
export interface IndicatorResult {
|
||||
id: string;
|
||||
type: IndicatorType;
|
||||
data: IndicatorDataPoint[];
|
||||
lines: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IndicatorDataPoint {
|
||||
time: number;
|
||||
values: Record<string, number | null>;
|
||||
}
|
||||
|
||||
export interface WorkerMessage {
|
||||
type: 'calculate' | 'cancel' | 'result' | 'error' | 'progress';
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface CalculateMessage {
|
||||
type: 'calculate';
|
||||
payload: {
|
||||
id: string;
|
||||
indicatorType: IndicatorType;
|
||||
ohlcv: OhlcvData[];
|
||||
parameters: Record<string, number | string>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OhlcvData {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface IndicatorParameterDef {
|
||||
name: string;
|
||||
type: 'number' | 'string' | 'boolean' | 'select';
|
||||
default: number | string | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface IndicatorDefinition {
|
||||
type: IndicatorType;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'trend' | 'momentum' | 'volatility' | 'volume';
|
||||
parameters: Record<string, IndicatorParameterDef>;
|
||||
defaultStyle: IndicatorStyle;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export const INDICATOR_DEFINITIONS: Record<IndicatorType, IndicatorDefinition> = {
|
||||
sma: {
|
||||
type: 'sma',
|
||||
name: 'Simple Moving Average',
|
||||
description: 'Arithmetic mean of prices over a period',
|
||||
category: 'trend',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
description: 'Number of periods to calculate'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close', 'hl2', 'hlc3', 'ohlc4'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#2196F3',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
},
|
||||
|
||||
ema: {
|
||||
type: 'ema',
|
||||
name: 'Exponential Moving Average',
|
||||
description: 'Weighted moving average giving more importance to recent prices',
|
||||
category: 'trend',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
description: 'Number of periods to calculate'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close', 'hl2', 'hlc3', 'ohlc4'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#FF9800',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
},
|
||||
|
||||
wma: {
|
||||
type: 'wma',
|
||||
name: 'Weighted Moving Average',
|
||||
description: 'Linearly weighted moving average',
|
||||
category: 'trend',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
description: 'Number of periods to calculate'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close', 'hl2', 'hlc3', 'ohlc4'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#9C27B0',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
},
|
||||
|
||||
bollinger: {
|
||||
type: 'bollinger',
|
||||
name: 'Bollinger Bands',
|
||||
description: 'Volatility bands placed above and below a moving average',
|
||||
category: 'volatility',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
description: 'Moving average period'
|
||||
},
|
||||
stdDev: {
|
||||
name: 'Standard Deviation',
|
||||
type: 'number',
|
||||
default: 2,
|
||||
min: 0.1,
|
||||
max: 5,
|
||||
step: 0.1,
|
||||
description: 'Number of standard deviations'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close', 'hl2', 'hlc3', 'ohlc4'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#E91E63',
|
||||
lineWidth: 1,
|
||||
lineStyle: 'solid',
|
||||
fillColor: '#E91E63',
|
||||
fillOpacity: 0.1
|
||||
},
|
||||
lines: ['upper', 'middle', 'lower']
|
||||
},
|
||||
|
||||
rsi: {
|
||||
type: 'rsi',
|
||||
name: 'Relative Strength Index',
|
||||
description: 'Momentum oscillator measuring speed and change of price movements',
|
||||
category: 'momentum',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 14,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: 'RSI calculation period'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#4CAF50',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
},
|
||||
|
||||
macd: {
|
||||
type: 'macd',
|
||||
name: 'MACD',
|
||||
description: 'Moving Average Convergence Divergence',
|
||||
category: 'momentum',
|
||||
parameters: {
|
||||
fast: {
|
||||
name: 'Fast Period',
|
||||
type: 'number',
|
||||
default: 12,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: 'Fast EMA period'
|
||||
},
|
||||
slow: {
|
||||
name: 'Slow Period',
|
||||
type: 'number',
|
||||
default: 26,
|
||||
min: 1,
|
||||
max: 200,
|
||||
step: 1,
|
||||
description: 'Slow EMA period'
|
||||
},
|
||||
signal: {
|
||||
name: 'Signal Period',
|
||||
type: 'number',
|
||||
default: 9,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: 'Signal line period'
|
||||
},
|
||||
source: {
|
||||
name: 'Source',
|
||||
type: 'select',
|
||||
default: 'close',
|
||||
options: ['open', 'high', 'low', 'close'],
|
||||
description: 'Price source for calculation'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#00BCD4',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['macd', 'signal', 'histogram']
|
||||
},
|
||||
|
||||
atr: {
|
||||
type: 'atr',
|
||||
name: 'Average True Range',
|
||||
description: 'Volatility indicator based on true ranges',
|
||||
category: 'volatility',
|
||||
parameters: {
|
||||
period: {
|
||||
name: 'Period',
|
||||
type: 'number',
|
||||
default: 14,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: 'ATR calculation period'
|
||||
}
|
||||
},
|
||||
defaultStyle: {
|
||||
color: '#FF5722',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
},
|
||||
|
||||
vwap: {
|
||||
type: 'vwap',
|
||||
name: 'VWAP',
|
||||
description: 'Volume Weighted Average Price',
|
||||
category: 'volume',
|
||||
parameters: {},
|
||||
defaultStyle: {
|
||||
color: '#607D8B',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid'
|
||||
},
|
||||
lines: ['value']
|
||||
}
|
||||
};
|
||||
|
||||
export function createIndicatorConfig(type: IndicatorType): IndicatorConfig {
|
||||
const def = INDICATOR_DEFINITIONS[type];
|
||||
const params: Record<string, number | string> = {};
|
||||
|
||||
Object.entries(def.parameters).forEach(([key, paramDef]) => {
|
||||
params[key] = paramDef.default;
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${type}_${Date.now()}`,
|
||||
type,
|
||||
name: def.name,
|
||||
visible: true,
|
||||
parameters: params,
|
||||
style: { ...def.defaultStyle }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Web Worker for Technical Indicator Calculations
|
||||
* Runs in background thread to prevent UI blocking
|
||||
*/
|
||||
|
||||
import type { OhlcvData, IndicatorResult, IndicatorType, WorkerMessage } from '../types/indicators';
|
||||
|
||||
// Indicator calculation functions
|
||||
class IndicatorCalculator {
|
||||
static calculateSMA(data: OhlcvData[], period: number, source: string): number[] {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < period; j++) {
|
||||
sum += prices[i - j];
|
||||
}
|
||||
result[i] = sum / period;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static calculateEMA(data: OhlcvData[], period: number, source: string): number[] {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
const multiplier = 2 / (period + 1);
|
||||
|
||||
// First EMA is SMA
|
||||
let sum = 0;
|
||||
for (let i = 0; i < period; i++) {
|
||||
sum += prices[i];
|
||||
}
|
||||
result[period - 1] = sum / period;
|
||||
|
||||
// Calculate remaining EMAs
|
||||
for (let i = period; i < data.length; i++) {
|
||||
result[i] = (prices[i] - (result[i - 1] ?? prices[i])) * multiplier + (result[i - 1] ?? prices[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static calculateWMA(data: OhlcvData[], period: number, source: string): number[] {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
const denominator = (period * (period + 1)) / 2;
|
||||
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
let weightedSum = 0;
|
||||
for (let j = 0; j < period; j++) {
|
||||
weightedSum += prices[i - j] * (period - j);
|
||||
}
|
||||
result[i] = weightedSum / denominator;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static calculateBollinger(data: OhlcvData[], period: number, stdDev: number, source: string): { upper: number[]; middle: number[]; lower: number[] } {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const middle = this.calculateSMA(data, period, source);
|
||||
const upper: number[] = new Array(data.length).fill(null as any);
|
||||
const lower: number[] = new Array(data.length).fill(null as any);
|
||||
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < period; j++) {
|
||||
const diff = prices[i - j] - (middle[i] ?? 0);
|
||||
sum += diff * diff;
|
||||
}
|
||||
const std = Math.sqrt(sum / period);
|
||||
upper[i] = (middle[i] ?? 0) + stdDev * std;
|
||||
lower[i] = (middle[i] ?? 0) - stdDev * std;
|
||||
}
|
||||
|
||||
return { upper, middle, lower };
|
||||
}
|
||||
|
||||
static calculateRSI(data: OhlcvData[], period: number, source: string): number[] {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
|
||||
// Calculate initial averages
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const change = prices[i] - prices[i - 1];
|
||||
if (change > 0) avgGain += change;
|
||||
else avgLoss += Math.abs(change);
|
||||
}
|
||||
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
|
||||
// First RSI
|
||||
const rs = avgGain / (avgLoss || 1);
|
||||
result[period] = 100 - (100 / (1 + rs));
|
||||
|
||||
// Calculate remaining RSI values
|
||||
for (let i = period + 1; i < data.length; i++) {
|
||||
const change = prices[i] - prices[i - 1];
|
||||
const gain = change > 0 ? change : 0;
|
||||
const loss = change < 0 ? Math.abs(change) : 0;
|
||||
|
||||
avgGain = ((avgGain * (period - 1)) + gain) / period;
|
||||
avgLoss = ((avgLoss * (period - 1)) + loss) / period;
|
||||
|
||||
const rs = avgGain / (avgLoss || 1);
|
||||
result[i] = 100 - (100 / (1 + rs));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static calculateMACD(
|
||||
data: OhlcvData[],
|
||||
fastPeriod: number,
|
||||
slowPeriod: number,
|
||||
signalPeriod: number,
|
||||
source: string
|
||||
): { macd: number[]; signal: number[]; histogram: number[] } {
|
||||
const prices = this.getSourcePrices(data, source);
|
||||
const fastEMA = this.calculateEMA(data, fastPeriod, source);
|
||||
const slowEMA = this.calculateEMA(data, slowPeriod, source);
|
||||
|
||||
const macd: number[] = new Array(data.length).fill(null as any);
|
||||
for (let i = slowPeriod - 1; i < data.length; i++) {
|
||||
macd[i] = (fastEMA[i] ?? 0) - (slowEMA[i] ?? 0);
|
||||
}
|
||||
|
||||
// Calculate signal line (EMA of MACD)
|
||||
const signal = this.calculateEMAOfArray(macd, signalPeriod, slowPeriod - 1);
|
||||
|
||||
// Calculate histogram
|
||||
const histogram: number[] = new Array(data.length).fill(null as any);
|
||||
for (let i = slowPeriod + signalPeriod - 2; i < data.length; i++) {
|
||||
histogram[i] = (macd[i] ?? 0) - (signal[i] ?? 0);
|
||||
}
|
||||
|
||||
return { macd, signal, histogram };
|
||||
}
|
||||
|
||||
static calculateATR(data: OhlcvData[], period: number): number[] {
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
const trueRanges: number[] = [];
|
||||
|
||||
// Calculate true ranges
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i === 0) {
|
||||
trueRanges.push(data[i].high - data[i].low);
|
||||
} else {
|
||||
const tr1 = data[i].high - data[i].low;
|
||||
const tr2 = Math.abs(data[i].high - data[i - 1].close);
|
||||
const tr3 = Math.abs(data[i].low - data[i - 1].close);
|
||||
trueRanges.push(Math.max(tr1, tr2, tr3));
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate ATR
|
||||
let sum = 0;
|
||||
for (let i = 0; i < period; i++) {
|
||||
sum += trueRanges[i];
|
||||
}
|
||||
result[period - 1] = sum / period;
|
||||
|
||||
for (let i = period; i < data.length; i++) {
|
||||
result[i] = ((result[i - 1] ?? 0) * (period - 1) + trueRanges[i]) / period;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static calculateVWAP(data: OhlcvData[]): number[] {
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
let cumulativeTPV = 0;
|
||||
let cumulativeVolume = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const typicalPrice = (data[i].high + data[i].low + data[i].close) / 3;
|
||||
const volume = data[i].volume ?? 0;
|
||||
|
||||
cumulativeTPV += typicalPrice * volume;
|
||||
cumulativeVolume += volume;
|
||||
|
||||
if (cumulativeVolume > 0) {
|
||||
result[i] = cumulativeTPV / cumulativeVolume;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static getSourcePrices(data: OhlcvData[], source: string): number[] {
|
||||
switch (source) {
|
||||
case 'open': return data.map(d => d.open);
|
||||
case 'high': return data.map(d => d.high);
|
||||
case 'low': return data.map(d => d.low);
|
||||
case 'hl2': return data.map(d => (d.high + d.low) / 2);
|
||||
case 'hlc3': return data.map(d => (d.high + d.low + d.close) / 3);
|
||||
case 'ohlc4': return data.map(d => (d.open + d.high + d.low + d.close) / 4);
|
||||
case 'close':
|
||||
default:
|
||||
return data.map(d => d.close);
|
||||
}
|
||||
}
|
||||
|
||||
private static calculateEMAOfArray(data: number[], period: number, startIndex: number): number[] {
|
||||
const result: number[] = new Array(data.length).fill(null as any);
|
||||
const multiplier = 2 / (period + 1);
|
||||
|
||||
// First EMA is SMA
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (let i = startIndex; i < startIndex + period && i < data.length; i++) {
|
||||
if (data[i] !== null) {
|
||||
sum += data[i];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
result[startIndex + period - 1] = sum / count;
|
||||
|
||||
// Calculate remaining EMAs
|
||||
for (let i = startIndex + period; i < data.length; i++) {
|
||||
if (data[i] !== null) {
|
||||
const prevEMA = result[i - 1] ?? data[i];
|
||||
result[i] = (data[i] - prevEMA) * multiplier + prevEMA;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Worker message handler
|
||||
self.onmessage = function(event: MessageEvent<WorkerMessage>) {
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'calculate') {
|
||||
const { id, indicatorType, ohlcv, parameters } = payload as {
|
||||
id: string;
|
||||
indicatorType: IndicatorType;
|
||||
ohlcv: OhlcvData[];
|
||||
parameters: Record<string, number | string>;
|
||||
};
|
||||
|
||||
try {
|
||||
let result: IndicatorResult;
|
||||
|
||||
switch (indicatorType) {
|
||||
case 'sma':
|
||||
result = calculateSMAIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'ema':
|
||||
result = calculateEMAIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'wma':
|
||||
result = calculateWMAIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'bollinger':
|
||||
result = calculateBollingerIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'rsi':
|
||||
result = calculateRSIIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'macd':
|
||||
result = calculateMACDIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'atr':
|
||||
result = calculateATRIndicator(id, ohlcv, parameters);
|
||||
break;
|
||||
case 'vwap':
|
||||
result = calculateVWAPIndicator(id, ohlcv);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown indicator type: ${indicatorType}`);
|
||||
}
|
||||
|
||||
self.postMessage({
|
||||
type: 'result',
|
||||
payload: result
|
||||
});
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
payload: {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Indicator calculation wrappers
|
||||
function calculateSMAIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 20;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const values = IndicatorCalculator.calculateSMA(ohlcv, period, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'sma',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateEMAIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 20;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const values = IndicatorCalculator.calculateEMA(ohlcv, period, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'ema',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateWMAIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 20;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const values = IndicatorCalculator.calculateWMA(ohlcv, period, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'wma',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateBollingerIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 20;
|
||||
const stdDev = Number(parameters.stdDev) || 2;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const { upper, middle, lower } = IndicatorCalculator.calculateBollinger(ohlcv, period, stdDev, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'bollinger',
|
||||
lines: ['upper', 'middle', 'lower'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: {
|
||||
upper: upper[i],
|
||||
middle: middle[i],
|
||||
lower: lower[i]
|
||||
}
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateRSIIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 14;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const values = IndicatorCalculator.calculateRSI(ohlcv, period, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'rsi',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateMACDIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const fast = Number(parameters.fast) || 12;
|
||||
const slow = Number(parameters.slow) || 26;
|
||||
const signal = Number(parameters.signal) || 9;
|
||||
const source = String(parameters.source) || 'close';
|
||||
const { macd, signal: signalLine, histogram } = IndicatorCalculator.calculateMACD(ohlcv, fast, slow, signal, source);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'macd',
|
||||
lines: ['macd', 'signal', 'histogram'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: {
|
||||
macd: macd[i],
|
||||
signal: signalLine[i],
|
||||
histogram: histogram[i]
|
||||
}
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateATRIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[],
|
||||
parameters: Record<string, number | string>
|
||||
): IndicatorResult {
|
||||
const period = Number(parameters.period) || 14;
|
||||
const values = IndicatorCalculator.calculateATR(ohlcv, period);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'atr',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function calculateVWAPIndicator(
|
||||
id: string,
|
||||
ohlcv: OhlcvData[]
|
||||
): IndicatorResult {
|
||||
const values = IndicatorCalculator.calculateVWAP(ohlcv);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'vwap',
|
||||
lines: ['value'],
|
||||
data: ohlcv.map((candle, i) => ({
|
||||
time: candle.time,
|
||||
values: { value: values[i] }
|
||||
})),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user