work on core
This commit is contained in:
parent
b8cefdb8cd
commit
44476da13f
10 changed files with 951 additions and 755 deletions
Binary file not shown.
|
|
@ -231,9 +231,11 @@ fn parse_backtest_config(obj: napi::JsObject) -> Result<BacktestConfig> {
|
|||
let commission: f64 = obj.get_named_property("commission")?;
|
||||
let slippage: f64 = obj.get_named_property("slippage")?;
|
||||
let data_frequency: String = obj.get_named_property("dataFrequency")?;
|
||||
let strategy: Option<String> = obj.get_named_property("strategy").ok();
|
||||
|
||||
Ok(BacktestConfig {
|
||||
name,
|
||||
strategy,
|
||||
symbols,
|
||||
start_time: DateTime::parse_from_rfc3339(&start_date)
|
||||
.map_err(|e| Error::from_reason(e.to_string()))?
|
||||
|
|
@ -419,12 +421,15 @@ impl Strategy for SimpleSMAStrategy {
|
|||
}
|
||||
|
||||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||
eprintln!("Fill received: {} {} @ {} - {}", quantity, symbol, price, side);
|
||||
eprintln!("🔸 SMA Strategy - Fill received: {} {} @ ${:.2} - {}", quantity, symbol, price, side);
|
||||
let current_pos = self.positions.get(symbol).copied().unwrap_or(0.0);
|
||||
let new_pos = if side == "buy" { current_pos + quantity } else { current_pos - quantity };
|
||||
|
||||
eprintln!(" Position change: {} -> {}", current_pos, new_pos);
|
||||
|
||||
if new_pos.abs() < 0.0001 {
|
||||
self.positions.remove(symbol);
|
||||
eprintln!(" Position closed");
|
||||
} else {
|
||||
self.positions.insert(symbol.to_string(), new_pos);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,13 +28,20 @@ pub struct CompletedTrade {
|
|||
pub price: f64,
|
||||
pub quantity: f64,
|
||||
pub commission: f64,
|
||||
pub position_after: f64, // Position size after this trade
|
||||
pub pnl: Option<f64>, // P&L if position was reduced/closed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BacktestConfig {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<String>,
|
||||
pub symbols: Vec<String>,
|
||||
#[serde(rename = "startDate")]
|
||||
pub start_time: DateTime<Utc>,
|
||||
#[serde(rename = "endDate")]
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub initial_capital: f64,
|
||||
pub commission: f64,
|
||||
|
|
@ -66,7 +73,18 @@ impl BacktestState {
|
|||
|
||||
pub fn update_portfolio_value(&mut self, value: f64) {
|
||||
self.portfolio_value = value;
|
||||
self.equity_curve.push((self.current_time, value));
|
||||
|
||||
// Only add a new equity curve point if the timestamp has changed
|
||||
// or if it's the first point
|
||||
if self.equity_curve.is_empty() ||
|
||||
self.equity_curve.last().map(|(t, _)| *t != self.current_time).unwrap_or(true) {
|
||||
self.equity_curve.push((self.current_time, value));
|
||||
} else {
|
||||
// Update the last point with the new value
|
||||
if let Some(last) = self.equity_curve.last_mut() {
|
||||
last.1 = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_pending_order(&mut self, order: Order) {
|
||||
|
|
@ -77,7 +95,7 @@ impl BacktestState {
|
|||
self.pending_orders.remove(order_id)
|
||||
}
|
||||
|
||||
pub fn record_fill(&mut self, symbol: String, side: Side, fill: Fill) {
|
||||
pub fn record_fill(&mut self, symbol: String, side: Side, fill: Fill, position_after: f64, position_before: f64, pnl: Option<f64>) {
|
||||
self.completed_trades.push(CompletedTrade {
|
||||
symbol,
|
||||
side,
|
||||
|
|
@ -85,6 +103,8 @@ impl BacktestState {
|
|||
price: fill.price,
|
||||
quantity: fill.quantity,
|
||||
commission: fill.commission,
|
||||
position_after,
|
||||
pnl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,18 @@ pub struct BacktestMetrics {
|
|||
pub expectancy: f64,
|
||||
pub calmar_ratio: f64,
|
||||
pub sortino_ratio: f64,
|
||||
// Missing fields required by web app
|
||||
pub final_value: f64,
|
||||
pub winning_trades: usize,
|
||||
pub losing_trades: usize,
|
||||
pub largest_win: f64,
|
||||
pub largest_loss: f64,
|
||||
pub annual_return: f64,
|
||||
}
|
||||
|
||||
// Individual trade (fill) structure for UI
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Trade {
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
|
|
@ -37,6 +45,7 @@ pub struct Trade {
|
|||
pub commission: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pnl: Option<f64>,
|
||||
pub position_after: f64, // Position size after this trade
|
||||
}
|
||||
|
||||
// Analytics data structure
|
||||
|
|
@ -79,7 +88,7 @@ pub struct BacktestResult {
|
|||
pub config: BacktestConfig,
|
||||
pub metrics: BacktestMetrics,
|
||||
pub equity: Vec<EquityDataPoint>,
|
||||
pub trades: Vec<CompletedTradeInfo>,
|
||||
pub trades: Vec<Trade>, // Now shows all individual fills
|
||||
pub positions: Vec<PositionInfo>,
|
||||
pub analytics: Analytics,
|
||||
pub execution_time: u64,
|
||||
|
|
@ -95,23 +104,6 @@ pub struct EquityPoint {
|
|||
pub value: f64,
|
||||
}
|
||||
|
||||
// Trade structure that web app expects (with entry/exit info)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletedTradeInfo {
|
||||
pub id: String,
|
||||
pub symbol: String,
|
||||
pub entry_date: String,
|
||||
pub exit_date: Option<String>,
|
||||
pub entry_price: f64,
|
||||
pub exit_price: f64,
|
||||
pub quantity: f64,
|
||||
pub side: String,
|
||||
pub pnl: f64,
|
||||
pub pnl_percent: f64,
|
||||
pub commission: f64,
|
||||
pub duration: i64, // milliseconds
|
||||
}
|
||||
|
||||
impl BacktestResult {
|
||||
pub fn from_engine_data(
|
||||
|
|
@ -138,7 +130,8 @@ impl BacktestResult {
|
|||
quantity: fill.quantity,
|
||||
price: fill.price,
|
||||
commission: fill.commission,
|
||||
pnl: None,
|
||||
pnl: fill.pnl,
|
||||
position_after: fill.position_after,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -239,6 +232,12 @@ impl BacktestResult {
|
|||
expectancy: 0.0,
|
||||
calmar_ratio,
|
||||
sortino_ratio: 0.0, // TODO: Calculate
|
||||
final_value,
|
||||
winning_trades: profitable_trades,
|
||||
losing_trades: 0,
|
||||
largest_win: 0.0,
|
||||
largest_loss: 0.0,
|
||||
annual_return: annualized_return * 100.0,
|
||||
};
|
||||
|
||||
// Create analytics
|
||||
|
|
@ -306,24 +305,22 @@ impl BacktestResult {
|
|||
let initial_capital = config.initial_capital;
|
||||
let final_value = equity_curve.last().map(|(_, v)| *v).unwrap_or(initial_capital);
|
||||
|
||||
// Convert completed trades to web app format
|
||||
let trades: Vec<CompletedTradeInfo> = completed_trades.iter()
|
||||
.map(|trade| CompletedTradeInfo {
|
||||
id: trade.id.clone(),
|
||||
symbol: trade.symbol.clone(),
|
||||
entry_date: trade.entry_time.to_rfc3339(),
|
||||
exit_date: Some(trade.exit_time.to_rfc3339()),
|
||||
entry_price: trade.entry_price,
|
||||
exit_price: trade.exit_price,
|
||||
quantity: trade.quantity,
|
||||
side: match trade.side {
|
||||
// Convert fills to web app format (all individual trades)
|
||||
let trades: Vec<Trade> = fills.iter()
|
||||
.enumerate()
|
||||
.map(|(i, fill)| Trade {
|
||||
id: format!("trade-{}", i + 1),
|
||||
timestamp: fill.timestamp.to_rfc3339(),
|
||||
symbol: fill.symbol.clone(),
|
||||
side: match fill.side {
|
||||
crate::Side::Buy => "buy".to_string(),
|
||||
crate::Side::Sell => "sell".to_string(),
|
||||
},
|
||||
pnl: trade.pnl,
|
||||
pnl_percent: trade.pnl_percent,
|
||||
commission: trade.commission,
|
||||
duration: trade.duration_seconds * 1000, // Convert to milliseconds
|
||||
quantity: fill.quantity,
|
||||
price: fill.price,
|
||||
commission: fill.commission,
|
||||
pnl: fill.pnl,
|
||||
position_after: fill.position_after,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -480,10 +477,21 @@ impl BacktestResult {
|
|||
quantity: fill.quantity,
|
||||
price: fill.price,
|
||||
commission: fill.commission,
|
||||
pnl: None,
|
||||
pnl: fill.pnl,
|
||||
position_after: fill.position_after,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Find largest win/loss
|
||||
let largest_win = winning_trades.iter()
|
||||
.map(|t| t.pnl)
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap_or(0.0);
|
||||
let largest_loss = losing_trades.iter()
|
||||
.map(|t| t.pnl)
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let metrics = BacktestMetrics {
|
||||
total_return,
|
||||
sharpe_ratio,
|
||||
|
|
@ -498,6 +506,12 @@ impl BacktestResult {
|
|||
expectancy,
|
||||
calmar_ratio,
|
||||
sortino_ratio,
|
||||
final_value,
|
||||
winning_trades: profitable_trades,
|
||||
losing_trades: losing_trades.len(),
|
||||
largest_win,
|
||||
largest_loss,
|
||||
annual_return: annualized_return * 100.0, // Convert to percentage
|
||||
};
|
||||
|
||||
// Calculate monthly returns
|
||||
|
|
|
|||
|
|
@ -93,6 +93,16 @@ impl TradeTracker {
|
|||
duration_seconds: (fill.timestamp - open_pos.entry_time).num_seconds(),
|
||||
};
|
||||
|
||||
eprintln!("📈 TRADE CLOSED: {} {} @ entry: ${:.2} ({}), exit: ${:.2} ({}) = P&L: ${:.2}",
|
||||
symbol,
|
||||
match open_pos.side { Side::Buy => "LONG", Side::Sell => "SHORT" },
|
||||
open_pos.entry_price,
|
||||
open_pos.entry_time.format("%Y-%m-%d"),
|
||||
fill.price,
|
||||
fill.timestamp.format("%Y-%m-%d"),
|
||||
pnl
|
||||
);
|
||||
|
||||
self.completed_trades.push(completed_trade);
|
||||
|
||||
// Update open position
|
||||
|
|
|
|||
|
|
@ -88,18 +88,24 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
// Get actual position from our tracking
|
||||
let current_position = self.current_positions.get(symbol).copied().unwrap_or(0.0);
|
||||
|
||||
// Entry signals - only when flat
|
||||
if current_position.abs() < 0.001 {
|
||||
if price < lower_band {
|
||||
// Price is oversold, buy
|
||||
eprintln!("Mean reversion: {} oversold at ${:.2}, buying (lower band: ${:.2}, mean: ${:.2})",
|
||||
symbol, price, lower_band, mean);
|
||||
// Entry signals - allow pyramiding up to 3x base position
|
||||
let max_long_position = self.position_size * 3.0;
|
||||
let max_short_position = -self.position_size * 3.0;
|
||||
|
||||
if price < lower_band && current_position < max_long_position {
|
||||
// Price is oversold, buy (or add to long position)
|
||||
let remaining_capacity = max_long_position - current_position;
|
||||
let trade_size = self.position_size.min(remaining_capacity);
|
||||
|
||||
if trade_size > 0.0 {
|
||||
eprintln!("Mean reversion: {} oversold at ${:.2}, buying {} shares (current: {}, lower band: ${:.2}, mean: ${:.2})",
|
||||
symbol, price, trade_size, current_position, lower_band, mean);
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
signal_type: SignalType::Buy,
|
||||
strength: 1.0,
|
||||
quantity: Some(self.position_size),
|
||||
quantity: Some(trade_size),
|
||||
reason: Some(format!(
|
||||
"Mean reversion buy: price ${:.2} < lower band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
||||
price, lower_band, mean, std_dev
|
||||
|
|
@ -112,16 +118,21 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
"price": price,
|
||||
})),
|
||||
});
|
||||
} else if price > upper_band {
|
||||
// Price is overbought, sell short
|
||||
eprintln!("Mean reversion: {} overbought at ${:.2}, selling short (upper band: ${:.2}, mean: ${:.2})",
|
||||
symbol, price, upper_band, mean);
|
||||
}
|
||||
} else if price > upper_band && current_position > max_short_position {
|
||||
// Price is overbought, sell short (or add to short position)
|
||||
let remaining_capacity = current_position - max_short_position;
|
||||
let trade_size = self.position_size.min(remaining_capacity);
|
||||
|
||||
if trade_size > 0.0 {
|
||||
eprintln!("Mean reversion: {} overbought at ${:.2}, selling {} shares short (current: {}, upper band: ${:.2}, mean: ${:.2})",
|
||||
symbol, price, trade_size, current_position, upper_band, mean);
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
signal_type: SignalType::Sell,
|
||||
strength: 1.0,
|
||||
quantity: Some(self.position_size),
|
||||
quantity: Some(trade_size),
|
||||
reason: Some(format!(
|
||||
"Mean reversion sell: price ${:.2} > upper band ${:.2} (mean: ${:.2}, std: ${:.2})",
|
||||
price, upper_band, mean, std_dev
|
||||
|
|
@ -136,16 +147,19 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
});
|
||||
}
|
||||
}
|
||||
// Exit signals - only when we have a position
|
||||
else if current_position > 0.0 {
|
||||
|
||||
// Exit signals based on current position
|
||||
if current_position > 0.0 {
|
||||
// We're long - check exit conditions
|
||||
let entry_price = self.entry_prices.get(symbol).copied().unwrap_or(price);
|
||||
let target_price = entry_price + (mean - entry_price) * self.exit_threshold;
|
||||
let stop_loss = lower_band - std_dev; // Stop loss below lower band
|
||||
|
||||
if price >= target_price {
|
||||
eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing long",
|
||||
symbol, target_price, entry_price, mean);
|
||||
// Exit if price reaches target or stop loss
|
||||
if price >= target_price || price <= stop_loss {
|
||||
let exit_reason = if price >= target_price { "target" } else { "stop_loss" };
|
||||
eprintln!("Mean reversion: {} exit long at ${:.2} ({}), closing {} shares",
|
||||
symbol, price, exit_reason, current_position);
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
|
|
@ -161,26 +175,8 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
"price": price,
|
||||
"entry_price": entry_price,
|
||||
"target_price": target_price,
|
||||
"exit_type": "target",
|
||||
})),
|
||||
});
|
||||
} else if price <= stop_loss {
|
||||
eprintln!("Mean reversion: {} hit stop loss ${:.2}, closing long",
|
||||
symbol, stop_loss);
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
signal_type: SignalType::Sell,
|
||||
strength: 1.0,
|
||||
quantity: Some(current_position),
|
||||
reason: Some(format!(
|
||||
"Mean reversion stop loss: price ${:.2} <= stop ${:.2}",
|
||||
price, stop_loss
|
||||
)),
|
||||
metadata: Some(json!({
|
||||
"stop_loss": stop_loss,
|
||||
"price": price,
|
||||
"exit_type": "stop_loss",
|
||||
"exit_type": exit_reason,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -190,9 +186,11 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
let target_price = entry_price - (entry_price - mean) * self.exit_threshold;
|
||||
let stop_loss = upper_band + std_dev; // Stop loss above upper band
|
||||
|
||||
if price <= target_price {
|
||||
eprintln!("Mean reversion: {} reached target ${:.2} (entry: ${:.2}, mean: ${:.2}), closing short",
|
||||
symbol, target_price, entry_price, mean);
|
||||
// Exit if price reaches target or stop loss
|
||||
if price <= target_price || price >= stop_loss {
|
||||
let exit_reason = if price <= target_price { "target" } else { "stop_loss" };
|
||||
eprintln!("Mean reversion: {} exit short at ${:.2} ({}), covering {} shares",
|
||||
symbol, price, exit_reason, current_position.abs());
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
|
|
@ -200,34 +198,16 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
strength: 1.0,
|
||||
quantity: Some(current_position.abs()),
|
||||
reason: Some(format!(
|
||||
"Mean reversion exit short: price ${:.2} reached target ${:.2} (entry: ${:.2})",
|
||||
price, target_price, entry_price
|
||||
"Mean reversion exit short: price ${:.2} {} (entry: ${:.2}, target: ${:.2}, stop: ${:.2})",
|
||||
price, exit_reason, entry_price, target_price, stop_loss
|
||||
)),
|
||||
metadata: Some(json!({
|
||||
"mean": mean,
|
||||
"price": price,
|
||||
"entry_price": entry_price,
|
||||
"target_price": target_price,
|
||||
"exit_type": "target",
|
||||
})),
|
||||
});
|
||||
} else if price >= stop_loss {
|
||||
eprintln!("Mean reversion: {} hit stop loss ${:.2}, closing short",
|
||||
symbol, stop_loss);
|
||||
|
||||
signals.push(Signal {
|
||||
symbol: symbol.clone(),
|
||||
signal_type: SignalType::Buy,
|
||||
strength: 1.0,
|
||||
quantity: Some(current_position.abs()),
|
||||
reason: Some(format!(
|
||||
"Mean reversion stop loss: price ${:.2} >= stop ${:.2}",
|
||||
price, stop_loss
|
||||
)),
|
||||
metadata: Some(json!({
|
||||
"stop_loss": stop_loss,
|
||||
"price": price,
|
||||
"exit_type": "stop_loss",
|
||||
"exit_type": exit_reason,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -241,7 +221,7 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||
// Update our position tracking based on actual fills
|
||||
let current = self.current_positions.get(symbol).copied().unwrap_or(0.0);
|
||||
let new_position = if side.contains("Buy") {
|
||||
let new_position = if side == "buy" {
|
||||
current + quantity
|
||||
} else {
|
||||
current - quantity
|
||||
|
|
@ -255,11 +235,20 @@ impl Strategy for MeanReversionFixedStrategy {
|
|||
// Position closed
|
||||
self.current_positions.remove(symbol);
|
||||
self.entry_prices.remove(symbol);
|
||||
eprintln!("Position closed for {}", symbol);
|
||||
} else {
|
||||
self.current_positions.insert(symbol.to_string(), new_position);
|
||||
// Track entry price for new positions
|
||||
|
||||
// Track average entry price
|
||||
if current.abs() < 0.001 {
|
||||
// New position - set initial entry price
|
||||
self.entry_prices.insert(symbol.to_string(), price);
|
||||
} else if (current > 0.0 && new_position > current) || (current < 0.0 && new_position < current) {
|
||||
// Adding to existing position - update average entry price
|
||||
let old_price = self.entry_prices.get(symbol).copied().unwrap_or(price);
|
||||
let avg_price = (old_price * current.abs() + price * quantity) / new_position.abs();
|
||||
self.entry_prices.insert(symbol.to_string(), avg_price);
|
||||
eprintln!("Updated avg entry price for {}: ${:.2}", symbol, avg_price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue