moving engine to rust
This commit is contained in:
parent
d14380d740
commit
16ac28a565
16 changed files with 1598 additions and 3 deletions
|
|
@ -13,6 +13,7 @@ serde = { version = "1.0", features = ["derive"] }
|
|||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
|
||||
# Data structures
|
||||
dashmap = "5.5"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ const nativeBinding = require(join(__dirname, 'index.node'));
|
|||
|
||||
export const {
|
||||
TradingEngine,
|
||||
BacktestEngine,
|
||||
TechnicalIndicators,
|
||||
IncrementalSMA,
|
||||
IncrementalEMA,
|
||||
IncrementalRSI,
|
||||
RiskAnalyzer,
|
||||
OrderbookAnalyzer,
|
||||
MarketData,
|
||||
MarketUpdate,
|
||||
Order,
|
||||
|
|
|
|||
Binary file not shown.
206
apps/stock/core/src/api/backtest.rs
Normal file
206
apps/stock/core/src/api/backtest.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
use napi::bindgen_prelude::*;
|
||||
use napi::{threadsafe_function::ThreadsafeFunction, JsObject};
|
||||
use napi_derive::napi;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use crate::backtest::{
|
||||
BacktestEngine as RustBacktestEngine,
|
||||
BacktestConfig,
|
||||
Strategy, Signal,
|
||||
strategy::{TypeScriptStrategy, StrategyCall, StrategyResponse},
|
||||
};
|
||||
use crate::{TradingMode, MarketUpdate};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[napi]
|
||||
pub struct BacktestEngine {
|
||||
inner: Arc<Mutex<Option<RustBacktestEngine>>>,
|
||||
strategies: Arc<Mutex<Vec<Arc<Mutex<TypeScriptStrategy>>>>>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BacktestEngine {
|
||||
#[napi(constructor)]
|
||||
pub fn new(config: napi::JsObject, env: Env) -> Result<Self> {
|
||||
let config = parse_backtest_config(config)?;
|
||||
|
||||
// Create mode
|
||||
let mode = TradingMode::Backtest {
|
||||
start_time: config.start_time,
|
||||
end_time: config.end_time,
|
||||
speed_multiplier: 0.0, // Max speed
|
||||
};
|
||||
|
||||
// Create components
|
||||
let time_provider = crate::core::create_time_provider(&mode);
|
||||
let market_data_source = crate::core::create_market_data_source(&mode);
|
||||
let execution_handler = crate::core::create_execution_handler(&mode);
|
||||
|
||||
let engine = RustBacktestEngine::new(
|
||||
config,
|
||||
mode,
|
||||
time_provider,
|
||||
market_data_source,
|
||||
execution_handler,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(Some(engine))),
|
||||
strategies: Arc::new(Mutex::new(Vec::new())),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn add_typescript_strategy(
|
||||
&mut self,
|
||||
name: String,
|
||||
id: String,
|
||||
parameters: napi::JsObject,
|
||||
callback: napi::JsFunction,
|
||||
) -> Result<()> {
|
||||
// Convert JsObject to serde_json::Value
|
||||
let params = serde_json::Value::Object(serde_json::Map::new());
|
||||
|
||||
let mut strategy = TypeScriptStrategy::new(name, id, params);
|
||||
|
||||
// Create a thread-safe callback wrapper
|
||||
let tsfn: ThreadsafeFunction<String> = callback
|
||||
.create_threadsafe_function(0, |ctx| {
|
||||
ctx.env.create_string_from_std(ctx.value)
|
||||
.map(|v| vec![v])
|
||||
})?;
|
||||
|
||||
// Set the callback that will call back into TypeScript
|
||||
let tsfn_clone = tsfn.clone();
|
||||
strategy.callback = Some(Box::new(move |call| {
|
||||
let call_json = serde_json::to_string(&call).unwrap_or_default();
|
||||
|
||||
// For now, return empty response - proper implementation needed
|
||||
let response = "{}".to_string();
|
||||
|
||||
serde_json::from_str(&response)
|
||||
.unwrap_or_else(|_| crate::backtest::strategy::StrategyResponse { signals: vec![] })
|
||||
}));
|
||||
|
||||
let strategy_arc = Arc::new(Mutex::new(strategy));
|
||||
self.strategies.lock().push(strategy_arc.clone());
|
||||
|
||||
// Add to engine
|
||||
if let Some(engine) = self.inner.lock().as_mut() {
|
||||
engine.add_strategy(Box::new(StrategyWrapper(strategy_arc)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn run(&mut self) -> Result<String> {
|
||||
let mut engine = self.inner.lock().take()
|
||||
.ok_or_else(|| Error::from_reason("Engine already consumed"))?;
|
||||
|
||||
// Run the backtest synchronously for now
|
||||
let runtime = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| Error::from_reason(e.to_string()))?;
|
||||
|
||||
let result = runtime.block_on(engine.run())
|
||||
.map_err(|e| Error::from_reason(e))?;
|
||||
|
||||
// Return result as JSON
|
||||
serde_json::to_string(&result)
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn load_market_data(&self, data: Vec<napi::JsObject>) -> Result<()> {
|
||||
// Convert JS objects to MarketData
|
||||
let market_data: Vec<MarketUpdate> = data.into_iter()
|
||||
.filter_map(|obj| parse_market_data(obj).ok())
|
||||
.collect();
|
||||
|
||||
// In real implementation, this would load into the market data source
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper to make TypeScriptStrategy implement Strategy trait
|
||||
struct StrategyWrapper(Arc<Mutex<TypeScriptStrategy>>);
|
||||
|
||||
impl Strategy for StrategyWrapper {
|
||||
fn on_market_data(&mut self, data: &MarketUpdate) -> Vec<Signal> {
|
||||
self.0.lock().on_market_data(data)
|
||||
}
|
||||
|
||||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||
self.0.lock().on_fill(symbol, quantity, price, side)
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
// This is a hack - in production, store name separately
|
||||
"typescript_strategy"
|
||||
}
|
||||
|
||||
fn get_parameters(&self) -> serde_json::Value {
|
||||
self.0.lock().parameters.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_backtest_config(obj: napi::JsObject) -> Result<BacktestConfig> {
|
||||
let name: String = obj.get_named_property("name")?;
|
||||
let symbols: Vec<String> = obj.get_named_property("symbols")?;
|
||||
let start_date: String = obj.get_named_property("startDate")?;
|
||||
let end_date: String = obj.get_named_property("endDate")?;
|
||||
let initial_capital: f64 = obj.get_named_property("initialCapital")?;
|
||||
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")?;
|
||||
|
||||
Ok(BacktestConfig {
|
||||
name,
|
||||
symbols,
|
||||
start_time: DateTime::parse_from_rfc3339(&start_date)
|
||||
.map_err(|e| Error::from_reason(e.to_string()))?
|
||||
.with_timezone(&Utc),
|
||||
end_time: DateTime::parse_from_rfc3339(&end_date)
|
||||
.map_err(|e| Error::from_reason(e.to_string()))?
|
||||
.with_timezone(&Utc),
|
||||
initial_capital,
|
||||
commission,
|
||||
slippage,
|
||||
data_frequency,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_market_data(obj: napi::JsObject) -> Result<crate::MarketUpdate> {
|
||||
let symbol: String = obj.get_named_property("symbol")?;
|
||||
let timestamp: i64 = obj.get_named_property("timestamp")?;
|
||||
let data_type: String = obj.get_named_property("type")?;
|
||||
|
||||
let data = if data_type == "bar" {
|
||||
crate::MarketDataType::Bar(crate::Bar {
|
||||
open: obj.get_named_property("open")?,
|
||||
high: obj.get_named_property("high")?,
|
||||
low: obj.get_named_property("low")?,
|
||||
close: obj.get_named_property("close")?,
|
||||
volume: obj.get_named_property("volume")?,
|
||||
vwap: obj.get_named_property("vwap").ok(),
|
||||
})
|
||||
} else {
|
||||
return Err(Error::from_reason("Unsupported market data type"));
|
||||
};
|
||||
|
||||
Ok(crate::MarketUpdate {
|
||||
symbol,
|
||||
timestamp: DateTime::<Utc>::from_timestamp(timestamp / 1000, 0)
|
||||
.ok_or_else(|| Error::from_reason("Invalid timestamp"))?,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// Error handling for threadsafe functions
|
||||
struct ErrorStrategy;
|
||||
|
||||
impl From<napi::Error> for ErrorStrategy {
|
||||
fn from(e: napi::Error) -> Self {
|
||||
ErrorStrategy
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
mod indicators;
|
||||
mod risk;
|
||||
mod backtest;
|
||||
|
||||
pub use indicators::{TechnicalIndicators, IncrementalSMA, IncrementalEMA, IncrementalRSI};
|
||||
pub use risk::{RiskAnalyzer, OrderbookAnalyzer};
|
||||
pub use backtest::BacktestEngine;
|
||||
|
||||
use napi_derive::napi;
|
||||
use napi::{bindgen_prelude::*, JsObject};
|
||||
|
|
|
|||
424
apps/stock/core/src/backtest/engine.rs
Normal file
424
apps/stock/core/src/backtest/engine.rs
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::{
|
||||
TradingMode, MarketDataSource, ExecutionHandler, TimeProvider,
|
||||
MarketUpdate, MarketDataType, Order, Fill, Side,
|
||||
positions::PositionTracker,
|
||||
risk::RiskEngine,
|
||||
orderbook::OrderBookManager,
|
||||
};
|
||||
|
||||
use super::{
|
||||
BacktestConfig, BacktestState, EventQueue, BacktestEvent, EventType,
|
||||
Strategy, Signal, SignalType, BacktestResult,
|
||||
};
|
||||
|
||||
pub struct BacktestEngine {
|
||||
config: BacktestConfig,
|
||||
state: Arc<RwLock<BacktestState>>,
|
||||
event_queue: Arc<RwLock<EventQueue>>,
|
||||
strategies: Arc<RwLock<Vec<Box<dyn Strategy>>>>,
|
||||
|
||||
// Core components
|
||||
position_tracker: Arc<PositionTracker>,
|
||||
risk_engine: Arc<RiskEngine>,
|
||||
orderbook_manager: Arc<OrderBookManager>,
|
||||
time_provider: Arc<Box<dyn TimeProvider>>,
|
||||
market_data_source: Arc<RwLock<Box<dyn MarketDataSource>>>,
|
||||
execution_handler: Arc<RwLock<Box<dyn ExecutionHandler>>>,
|
||||
|
||||
// Metrics
|
||||
total_trades: usize,
|
||||
profitable_trades: usize,
|
||||
total_pnl: f64,
|
||||
}
|
||||
|
||||
impl BacktestEngine {
|
||||
pub fn new(
|
||||
config: BacktestConfig,
|
||||
mode: TradingMode,
|
||||
time_provider: Box<dyn TimeProvider>,
|
||||
market_data_source: Box<dyn MarketDataSource>,
|
||||
execution_handler: Box<dyn ExecutionHandler>,
|
||||
) -> Self {
|
||||
let state = Arc::new(RwLock::new(
|
||||
BacktestState::new(config.initial_capital, config.start_time)
|
||||
));
|
||||
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
event_queue: Arc::new(RwLock::new(EventQueue::new())),
|
||||
strategies: Arc::new(RwLock::new(Vec::new())),
|
||||
position_tracker: Arc::new(PositionTracker::new()),
|
||||
risk_engine: Arc::new(RiskEngine::new()),
|
||||
orderbook_manager: Arc::new(OrderBookManager::new()),
|
||||
time_provider: Arc::new(time_provider),
|
||||
market_data_source: Arc::new(RwLock::new(market_data_source)),
|
||||
execution_handler: Arc::new(RwLock::new(execution_handler)),
|
||||
total_trades: 0,
|
||||
profitable_trades: 0,
|
||||
total_pnl: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_strategy(&mut self, strategy: Box<dyn Strategy>) {
|
||||
self.strategies.write().push(strategy);
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<BacktestResult, String> {
|
||||
// Initialize start time
|
||||
if let Some(simulated_time) = self.time_provider.as_any()
|
||||
.downcast_ref::<crate::core::time_providers::SimulatedTime>()
|
||||
{
|
||||
simulated_time.advance_to(self.config.start_time);
|
||||
}
|
||||
|
||||
// Load market data
|
||||
self.load_market_data().await?;
|
||||
|
||||
// Main event loop
|
||||
while !self.event_queue.read().is_empty() ||
|
||||
self.time_provider.now() < self.config.end_time
|
||||
{
|
||||
// Get next batch of events
|
||||
let current_time = self.time_provider.now();
|
||||
let events = self.event_queue.write().pop_until(current_time);
|
||||
|
||||
for event in events {
|
||||
self.process_event(event).await?;
|
||||
}
|
||||
|
||||
// Update portfolio value
|
||||
self.update_portfolio_value();
|
||||
|
||||
// Check if we should advance time
|
||||
if self.event_queue.read().is_empty() {
|
||||
// Advance to next data point or end time
|
||||
if let Some(next_time) = self.get_next_event_time() {
|
||||
if next_time < self.config.end_time {
|
||||
self.advance_time(next_time);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate results
|
||||
Ok(self.generate_results())
|
||||
}
|
||||
|
||||
async fn load_market_data(&mut self) -> Result<(), String> {
|
||||
let mut data_source = self.market_data_source.write();
|
||||
|
||||
// Seek to start time
|
||||
data_source.seek_to_time(self.config.start_time)?;
|
||||
|
||||
// Load all data into event queue
|
||||
while let Some(update) = data_source.get_next_update().await {
|
||||
if update.timestamp > self.config.end_time {
|
||||
break;
|
||||
}
|
||||
|
||||
let event = BacktestEvent::market_data(update.timestamp, update);
|
||||
self.event_queue.write().push(event);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_event(&mut self, event: BacktestEvent) -> Result<(), String> {
|
||||
match event.event_type {
|
||||
EventType::MarketData(data) => {
|
||||
self.process_market_data(data).await?;
|
||||
}
|
||||
EventType::OrderSubmitted(order) => {
|
||||
self.process_order_submission(order).await?;
|
||||
}
|
||||
EventType::OrderFilled(fill) => {
|
||||
// Fills are already processed when orders are executed
|
||||
// This event is just for recording
|
||||
self.state.write().record_fill(fill);
|
||||
}
|
||||
EventType::OrderCancelled(order_id) => {
|
||||
self.process_order_cancellation(&order_id)?;
|
||||
}
|
||||
EventType::TimeUpdate(time) => {
|
||||
self.advance_time(time);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_market_data(&mut self, data: MarketUpdate) -> Result<(), String> {
|
||||
// Update orderbook if it's quote data
|
||||
match &data.data {
|
||||
MarketDataType::Quote(quote) => {
|
||||
// For now, skip orderbook updates
|
||||
// self.orderbook_manager.update_quote(&data.symbol, quote.bid, quote.ask);
|
||||
}
|
||||
MarketDataType::Trade(trade) => {
|
||||
// For now, skip orderbook updates
|
||||
// self.orderbook_manager.update_last_trade(&data.symbol, trade.price, trade.size);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Convert to simpler MarketData for strategies
|
||||
let market_data = self.convert_to_market_data(&data);
|
||||
|
||||
// Send to strategies
|
||||
let mut all_signals = Vec::new();
|
||||
{
|
||||
let mut strategies = self.strategies.write();
|
||||
for strategy in strategies.iter_mut() {
|
||||
let signals = strategy.on_market_data(&market_data);
|
||||
all_signals.extend(signals);
|
||||
}
|
||||
}
|
||||
|
||||
// Process signals
|
||||
for signal in all_signals {
|
||||
self.process_signal(signal).await?;
|
||||
}
|
||||
|
||||
// Check pending orders for fills
|
||||
self.check_pending_orders(&data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_to_market_data(&self, update: &MarketUpdate) -> MarketUpdate {
|
||||
// MarketData is a type alias for MarketUpdate
|
||||
update.clone()
|
||||
}
|
||||
|
||||
async fn process_signal(&mut self, signal: Signal) -> Result<(), String> {
|
||||
// Only process strong signals
|
||||
if signal.strength.abs() < 0.7 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Convert signal to order
|
||||
let order = self.signal_to_order(signal)?;
|
||||
|
||||
// Submit order
|
||||
self.process_order_submission(order).await
|
||||
}
|
||||
|
||||
fn signal_to_order(&self, signal: Signal) -> Result<Order, String> {
|
||||
let quantity = signal.quantity.unwrap_or_else(|| {
|
||||
// Calculate position size based on portfolio
|
||||
self.calculate_position_size(&signal.symbol, signal.strength)
|
||||
});
|
||||
|
||||
let side = match signal.signal_type {
|
||||
SignalType::Buy => Side::Buy,
|
||||
SignalType::Sell => Side::Sell,
|
||||
SignalType::Close => {
|
||||
// Determine side based on current position
|
||||
let position = self.position_tracker.get_position(&signal.symbol);
|
||||
if position.as_ref().map(|p| p.quantity > 0.0).unwrap_or(false) {
|
||||
Side::Sell
|
||||
} else {
|
||||
Side::Buy
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(crate::Order {
|
||||
id: format!("order_{}", uuid::Uuid::new_v4()),
|
||||
symbol: signal.symbol,
|
||||
side,
|
||||
quantity,
|
||||
order_type: crate::OrderType::Market,
|
||||
time_in_force: crate::TimeInForce::Day,
|
||||
})
|
||||
}
|
||||
|
||||
async fn process_order_submission(&mut self, order: Order) -> Result<(), String> {
|
||||
// Risk checks
|
||||
// Get current position for the symbol
|
||||
let current_position = self.position_tracker
|
||||
.get_position(&order.symbol)
|
||||
.map(|p| p.quantity);
|
||||
|
||||
let risk_check = self.risk_engine.check_order(&order, current_position);
|
||||
if !risk_check.passed {
|
||||
return Err(format!("Risk check failed: {:?}", risk_check.violations));
|
||||
}
|
||||
|
||||
// Add to pending orders
|
||||
self.state.write().add_pending_order(order.clone());
|
||||
|
||||
// For market orders in backtesting, fill immediately
|
||||
if matches!(order.order_type, crate::OrderType::Market) {
|
||||
self.check_order_fill(&order).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_pending_orders(&mut self, market_data: &MarketUpdate) -> Result<(), String> {
|
||||
let orders_to_check: Vec<Order> = {
|
||||
let state = self.state.read();
|
||||
state.pending_orders.values()
|
||||
.filter(|o| o.symbol == market_data.symbol)
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
for order in orders_to_check {
|
||||
self.check_order_fill(&order).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_order_fill(&mut self, order: &Order) -> Result<(), String> {
|
||||
// Get current market price
|
||||
// For now, use a simple fill model with last known price
|
||||
// In a real backtest, this would use orderbook data
|
||||
let base_price = 100.0; // TODO: Get from market data
|
||||
|
||||
// Apply slippage
|
||||
let fill_price = match order.side {
|
||||
crate::Side::Buy => base_price * (1.0 + self.config.slippage),
|
||||
crate::Side::Sell => base_price * (1.0 - self.config.slippage),
|
||||
};
|
||||
|
||||
// Create fill
|
||||
let fill = crate::Fill {
|
||||
timestamp: self.time_provider.now(),
|
||||
price: fill_price,
|
||||
quantity: order.quantity,
|
||||
commission: order.quantity * fill_price * self.config.commission,
|
||||
};
|
||||
|
||||
// Process the fill
|
||||
self.process_fill(&order, fill).await
|
||||
}
|
||||
|
||||
async fn process_fill(&mut self, order: &crate::Order, fill: crate::Fill) -> Result<(), String> {
|
||||
// Remove from pending orders
|
||||
self.state.write().remove_pending_order(&order.id);
|
||||
|
||||
// Update positions
|
||||
let update = self.position_tracker.process_fill(
|
||||
&order.symbol,
|
||||
&fill,
|
||||
order.side,
|
||||
);
|
||||
|
||||
// Record the fill
|
||||
self.state.write().record_fill(fill.clone());
|
||||
|
||||
// Update cash
|
||||
let cash_change = match order.side {
|
||||
crate::Side::Buy => -(fill.quantity * fill.price + fill.commission),
|
||||
crate::Side::Sell => fill.quantity * fill.price - fill.commission,
|
||||
};
|
||||
self.state.write().cash += cash_change;
|
||||
|
||||
// Notify strategies
|
||||
{
|
||||
let mut strategies = self.strategies.write();
|
||||
for strategy in strategies.iter_mut() {
|
||||
strategy.on_fill(&order.symbol, fill.quantity, fill.price,
|
||||
&format!("{:?}", order.side));
|
||||
}
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
self.total_trades += 1;
|
||||
if update.resulting_position.realized_pnl > 0.0 {
|
||||
self.profitable_trades += 1;
|
||||
}
|
||||
self.total_pnl = update.resulting_position.realized_pnl;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_order_cancellation(&mut self, order_id: &str) -> Result<(), String> {
|
||||
self.state.write().remove_pending_order(order_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn advance_time(&mut self, time: DateTime<Utc>) {
|
||||
if let Some(simulated_time) = self.time_provider.as_any()
|
||||
.downcast_ref::<crate::core::time_providers::SimulatedTime>()
|
||||
{
|
||||
simulated_time.advance_to(time);
|
||||
}
|
||||
self.state.write().current_time = time;
|
||||
}
|
||||
|
||||
fn update_portfolio_value(&mut self) {
|
||||
let positions = self.position_tracker.get_all_positions();
|
||||
let mut portfolio_value = self.state.read().cash;
|
||||
|
||||
for position in positions {
|
||||
// For now, use a simple market value calculation
|
||||
let market_value = position.quantity * 100.0; // TODO: Get actual price
|
||||
portfolio_value += market_value;
|
||||
}
|
||||
|
||||
self.state.write().update_portfolio_value(portfolio_value);
|
||||
}
|
||||
|
||||
fn calculate_position_size(&self, symbol: &str, signal_strength: f64) -> f64 {
|
||||
let portfolio_value = self.state.read().portfolio_value;
|
||||
let allocation = 0.1; // 10% per position
|
||||
let position_value = portfolio_value * allocation * signal_strength.abs();
|
||||
let price = 100.0; // TODO: Get actual price from market data
|
||||
|
||||
(position_value / price).floor()
|
||||
}
|
||||
|
||||
fn get_next_event_time(&self) -> Option<DateTime<Utc>> {
|
||||
// In a real implementation, this would look at the next market data point
|
||||
None
|
||||
}
|
||||
|
||||
fn generate_results(&self) -> BacktestResult {
|
||||
let state = self.state.read();
|
||||
let (realized_pnl, unrealized_pnl) = self.position_tracker.get_total_pnl();
|
||||
let total_pnl = realized_pnl + unrealized_pnl;
|
||||
let total_return = (total_pnl / self.config.initial_capital) * 100.0;
|
||||
|
||||
BacktestResult {
|
||||
config: self.config.clone(),
|
||||
metrics: super::BacktestMetrics {
|
||||
total_return,
|
||||
total_trades: self.total_trades,
|
||||
profitable_trades: self.profitable_trades,
|
||||
win_rate: if self.total_trades > 0 {
|
||||
(self.profitable_trades as f64 / self.total_trades as f64) * 100.0
|
||||
} else { 0.0 },
|
||||
profit_factor: 0.0, // TODO: Calculate properly
|
||||
sharpe_ratio: 0.0, // TODO: Calculate properly
|
||||
max_drawdown: 0.0, // TODO: Calculate properly
|
||||
total_pnl,
|
||||
avg_win: 0.0, // TODO: Calculate properly
|
||||
avg_loss: 0.0, // TODO: Calculate properly
|
||||
},
|
||||
equity_curve: state.equity_curve.clone(),
|
||||
trades: state.completed_trades.clone(),
|
||||
final_positions: self.position_tracker.get_all_positions()
|
||||
.into_iter()
|
||||
.map(|p| (p.symbol.clone(), p))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add uuid dependency
|
||||
use uuid::Uuid;
|
||||
55
apps/stock/core/src/backtest/event.rs
Normal file
55
apps/stock/core/src/backtest/event.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::{MarketUpdate, Order, Fill};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EventType {
|
||||
MarketData(MarketUpdate),
|
||||
OrderSubmitted(Order),
|
||||
OrderFilled(Fill),
|
||||
OrderCancelled(String), // order_id
|
||||
TimeUpdate(DateTime<Utc>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestEvent {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub event_type: EventType,
|
||||
}
|
||||
|
||||
impl BacktestEvent {
|
||||
pub fn market_data(timestamp: DateTime<Utc>, data: MarketUpdate) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
event_type: EventType::MarketData(data),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_submitted(timestamp: DateTime<Utc>, order: Order) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
event_type: EventType::OrderSubmitted(order),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_filled(timestamp: DateTime<Utc>, fill: Fill) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
event_type: EventType::OrderFilled(fill),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_cancelled(timestamp: DateTime<Utc>, order_id: String) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
event_type: EventType::OrderCancelled(order_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_update(timestamp: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
timestamp,
|
||||
event_type: EventType::TimeUpdate(timestamp),
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/stock/core/src/backtest/mod.rs
Normal file
112
apps/stock/core/src/backtest/mod.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
use crate::{MarketUpdate, Order, Fill, TradingMode, MarketDataSource, ExecutionHandler, TimeProvider};
|
||||
use crate::positions::PositionTracker;
|
||||
use crate::risk::RiskEngine;
|
||||
use crate::orderbook::OrderBookManager;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod strategy;
|
||||
pub mod results;
|
||||
|
||||
pub use engine::BacktestEngine;
|
||||
pub use event::{BacktestEvent, EventType};
|
||||
pub use strategy::{Strategy, Signal, SignalType};
|
||||
pub use results::{BacktestResult, BacktestMetrics};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestConfig {
|
||||
pub name: String,
|
||||
pub symbols: Vec<String>,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub initial_capital: f64,
|
||||
pub commission: f64,
|
||||
pub slippage: f64,
|
||||
pub data_frequency: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BacktestState {
|
||||
pub current_time: DateTime<Utc>,
|
||||
pub portfolio_value: f64,
|
||||
pub cash: f64,
|
||||
pub equity_curve: Vec<(DateTime<Utc>, f64)>,
|
||||
pub pending_orders: BTreeMap<String, Order>,
|
||||
pub completed_trades: Vec<Fill>,
|
||||
}
|
||||
|
||||
impl BacktestState {
|
||||
pub fn new(initial_capital: f64, start_time: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
current_time: start_time,
|
||||
portfolio_value: initial_capital,
|
||||
cash: initial_capital,
|
||||
equity_curve: vec![(start_time, initial_capital)],
|
||||
pending_orders: BTreeMap::new(),
|
||||
completed_trades: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_portfolio_value(&mut self, value: f64) {
|
||||
self.portfolio_value = value;
|
||||
self.equity_curve.push((self.current_time, value));
|
||||
}
|
||||
|
||||
pub fn add_pending_order(&mut self, order: Order) {
|
||||
self.pending_orders.insert(order.id.clone(), order);
|
||||
}
|
||||
|
||||
pub fn remove_pending_order(&mut self, order_id: &str) -> Option<Order> {
|
||||
self.pending_orders.remove(order_id)
|
||||
}
|
||||
|
||||
pub fn record_fill(&mut self, fill: Fill) {
|
||||
self.completed_trades.push(fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Event queue for deterministic replay
|
||||
#[derive(Debug)]
|
||||
pub struct EventQueue {
|
||||
events: VecDeque<BacktestEvent>,
|
||||
}
|
||||
|
||||
impl EventQueue {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, event: BacktestEvent) {
|
||||
// Insert in time order
|
||||
let pos = self.events.iter().position(|e| e.timestamp > event.timestamp)
|
||||
.unwrap_or(self.events.len());
|
||||
self.events.insert(pos, event);
|
||||
}
|
||||
|
||||
pub fn pop_until(&mut self, timestamp: DateTime<Utc>) -> Vec<BacktestEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = self.events.front() {
|
||||
if event.timestamp <= timestamp {
|
||||
events.push(self.events.pop_front().unwrap());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
}
|
||||
28
apps/stock/core/src/backtest/results.rs
Normal file
28
apps/stock/core/src/backtest/results.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::collections::HashMap;
|
||||
use crate::{Fill, Position};
|
||||
use super::BacktestConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
pub total_return: f64,
|
||||
pub total_trades: usize,
|
||||
pub profitable_trades: usize,
|
||||
pub win_rate: f64,
|
||||
pub profit_factor: f64,
|
||||
pub sharpe_ratio: f64,
|
||||
pub max_drawdown: f64,
|
||||
pub total_pnl: f64,
|
||||
pub avg_win: f64,
|
||||
pub avg_loss: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestResult {
|
||||
pub config: BacktestConfig,
|
||||
pub metrics: BacktestMetrics,
|
||||
pub equity_curve: Vec<(DateTime<Utc>, f64)>,
|
||||
pub trades: Vec<Fill>,
|
||||
pub final_positions: HashMap<String, Position>,
|
||||
}
|
||||
100
apps/stock/core/src/backtest/strategy.rs
Normal file
100
apps/stock/core/src/backtest/strategy.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::MarketData;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SignalType {
|
||||
Buy,
|
||||
Sell,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Signal {
|
||||
pub symbol: String,
|
||||
pub signal_type: SignalType,
|
||||
pub strength: f64, // -1.0 to 1.0
|
||||
pub quantity: Option<f64>,
|
||||
pub reason: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// This trait will be implemented by Rust strategies
|
||||
// TypeScript strategies will communicate through FFI
|
||||
pub trait Strategy: Send + Sync {
|
||||
fn on_market_data(&mut self, data: &MarketData) -> Vec<Signal>;
|
||||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str);
|
||||
fn get_name(&self) -> &str;
|
||||
fn get_parameters(&self) -> serde_json::Value;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyCall {
|
||||
pub method: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StrategyResponse {
|
||||
pub signals: Vec<Signal>,
|
||||
}
|
||||
|
||||
// Bridge for TypeScript strategies
|
||||
|
||||
// This will be used to wrap TypeScript strategies
|
||||
pub struct TypeScriptStrategy {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub parameters: serde_json::Value,
|
||||
// Callback function will be injected from TypeScript
|
||||
pub callback: Option<Box<dyn Fn(StrategyCall) -> StrategyResponse + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl TypeScriptStrategy {
|
||||
pub fn new(name: String, id: String, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
name,
|
||||
id,
|
||||
parameters,
|
||||
callback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for TypeScriptStrategy {
|
||||
fn on_market_data(&mut self, data: &MarketData) -> Vec<Signal> {
|
||||
if let Some(callback) = &self.callback {
|
||||
let call = StrategyCall {
|
||||
method: "on_market_data".to_string(),
|
||||
data: serde_json::to_value(data).unwrap_or_default(),
|
||||
};
|
||||
let response = callback(call);
|
||||
response.signals
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_fill(&mut self, symbol: &str, quantity: f64, price: f64, side: &str) {
|
||||
if let Some(callback) = &self.callback {
|
||||
let call = StrategyCall {
|
||||
method: "on_fill".to_string(),
|
||||
data: serde_json::json!({
|
||||
"symbol": symbol,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"side": side
|
||||
}),
|
||||
};
|
||||
callback(call);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn get_parameters(&self) -> serde_json::Value {
|
||||
self.parameters.clone()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,11 +7,15 @@ pub mod positions;
|
|||
pub mod api;
|
||||
pub mod analytics;
|
||||
pub mod indicators;
|
||||
pub mod backtest;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use positions::{Position, PositionUpdate, TradeRecord, ClosedTrade};
|
||||
pub use risk::{RiskLimits, RiskCheckResult, RiskMetrics};
|
||||
|
||||
// Type alias for backtest compatibility
|
||||
pub type MarketData = MarketUpdate;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
187
apps/stock/orchestrator/src/backtest/RustBacktestEngine.ts
Normal file
187
apps/stock/orchestrator/src/backtest/RustBacktestEngine.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { BacktestEngine as RustEngine } from '@stock-bot/core';
|
||||
import { RustStrategy } from '../strategies/RustStrategy';
|
||||
import { MarketData, BacktestConfig } from '../types';
|
||||
import { StorageService } from '../services/StorageService';
|
||||
import { IServiceContainer } from '@stock-bot/di';
|
||||
|
||||
export interface RustBacktestConfig {
|
||||
name: string;
|
||||
symbols: string[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
initialCapital: number;
|
||||
commission: number;
|
||||
slippage: number;
|
||||
dataFrequency: string;
|
||||
}
|
||||
|
||||
export interface RustBacktestResult {
|
||||
config: RustBacktestConfig;
|
||||
metrics: {
|
||||
totalReturn: number;
|
||||
totalTrades: number;
|
||||
profitableTrades: number;
|
||||
winRate: number;
|
||||
profitFactor: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
totalPnl: number;
|
||||
avgWin: number;
|
||||
avgLoss: number;
|
||||
};
|
||||
equityCurve: Array<{ date: string; value: number }>;
|
||||
trades: any[];
|
||||
finalPositions: Record<string, any>;
|
||||
}
|
||||
|
||||
export class RustBacktestEngine {
|
||||
private engine: RustEngine;
|
||||
private container: IServiceContainer;
|
||||
private storageService: StorageService;
|
||||
private config: RustBacktestConfig;
|
||||
|
||||
constructor(
|
||||
container: IServiceContainer,
|
||||
storageService: StorageService,
|
||||
config: BacktestConfig
|
||||
) {
|
||||
this.container = container;
|
||||
this.storageService = storageService;
|
||||
|
||||
// Convert config for Rust
|
||||
const rustConfig: RustBacktestConfig = {
|
||||
name: config.name || 'Backtest',
|
||||
symbols: config.symbols,
|
||||
startDate: config.startDate,
|
||||
endDate: config.endDate,
|
||||
initialCapital: config.initialCapital,
|
||||
commission: config.commission || 0.001,
|
||||
slippage: config.slippage || 0.0001,
|
||||
dataFrequency: config.dataFrequency || '1d',
|
||||
};
|
||||
|
||||
this.config = rustConfig;
|
||||
this.engine = new RustEngine(rustConfig as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a TypeScript strategy to the backtest
|
||||
*/
|
||||
addStrategy(strategy: RustStrategy): void {
|
||||
strategy.register(this.engine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load historical data for the backtest
|
||||
*/
|
||||
async loadData(): Promise<void> {
|
||||
// Get config from engine
|
||||
const config = this.getConfig();
|
||||
|
||||
const startDate = new Date(config.startDate);
|
||||
const endDate = new Date(config.endDate);
|
||||
|
||||
// Load data for each symbol
|
||||
for (const symbol of config.symbols) {
|
||||
const bars = await this.storageService.getHistoricalBars(
|
||||
symbol,
|
||||
startDate,
|
||||
endDate,
|
||||
config.dataFrequency
|
||||
);
|
||||
|
||||
// Convert to Rust format
|
||||
const marketData = bars.map(bar => ({
|
||||
symbol,
|
||||
timestamp: bar.timestamp.getTime(), // Convert to milliseconds
|
||||
type: 'bar',
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
volume: bar.volume,
|
||||
vwap: bar.vwap || (bar.high + bar.low + bar.close) / 3,
|
||||
}));
|
||||
|
||||
// Load into Rust engine
|
||||
console.log(`Loading ${marketData.length} bars for ${symbol}`);
|
||||
this.engine.loadMarketData(marketData as any);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the backtest
|
||||
*/
|
||||
async run(): Promise<RustBacktestResult> {
|
||||
console.log('Starting backtest run...');
|
||||
|
||||
// Load data first
|
||||
await this.loadData();
|
||||
|
||||
// Run backtest in Rust
|
||||
const resultJson = await this.engine.run();
|
||||
|
||||
// Parse result and convert snake_case to camelCase
|
||||
const rustResult = JSON.parse(resultJson);
|
||||
|
||||
const result: RustBacktestResult = {
|
||||
config: rustResult.config,
|
||||
metrics: {
|
||||
totalReturn: rustResult.metrics.total_return,
|
||||
totalTrades: rustResult.metrics.total_trades,
|
||||
profitableTrades: rustResult.metrics.profitable_trades,
|
||||
winRate: rustResult.metrics.win_rate,
|
||||
profitFactor: rustResult.metrics.profit_factor,
|
||||
sharpeRatio: rustResult.metrics.sharpe_ratio,
|
||||
maxDrawdown: rustResult.metrics.max_drawdown,
|
||||
totalPnl: rustResult.metrics.total_pnl,
|
||||
avgWin: rustResult.metrics.avg_win,
|
||||
avgLoss: rustResult.metrics.avg_loss,
|
||||
},
|
||||
equityCurve: rustResult.equity_curve.map((point: any) => ({
|
||||
date: point[0],
|
||||
value: point[1],
|
||||
})),
|
||||
trades: rustResult.trades,
|
||||
finalPositions: rustResult.final_positions,
|
||||
};
|
||||
|
||||
// Log results
|
||||
this.container.logger.info('Rust backtest completed', {
|
||||
totalReturn: result.metrics.totalReturn,
|
||||
totalTrades: result.metrics.totalTrades,
|
||||
sharpeRatio: result.metrics.sharpeRatio,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backtest configuration
|
||||
*/
|
||||
private getConfig(): RustBacktestConfig {
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a Rust-powered backtest
|
||||
*/
|
||||
export async function createRustBacktest(
|
||||
container: IServiceContainer,
|
||||
config: BacktestConfig,
|
||||
strategies: RustStrategy[]
|
||||
): Promise<RustBacktestResult> {
|
||||
const storageService = container.custom?.StorageService || new StorageService();
|
||||
|
||||
// Create engine
|
||||
const engine = new RustBacktestEngine(container, storageService, config);
|
||||
|
||||
// Add strategies
|
||||
for (const strategy of strategies) {
|
||||
engine.addStrategy(strategy);
|
||||
}
|
||||
|
||||
// Run backtest
|
||||
return await engine.run();
|
||||
}
|
||||
139
apps/stock/orchestrator/src/strategies/RustStrategy.ts
Normal file
139
apps/stock/orchestrator/src/strategies/RustStrategy.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { BacktestEngine } from '@stock-bot/core';
|
||||
import { MarketData } from '../types';
|
||||
|
||||
export interface Signal {
|
||||
symbol: string;
|
||||
signal_type: 'Buy' | 'Sell' | 'Close';
|
||||
strength: number; // -1.0 to 1.0
|
||||
quantity?: number;
|
||||
reason?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface StrategyCall {
|
||||
method: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface StrategyResponse {
|
||||
signals: Signal[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for TypeScript strategies that run in the Rust backtest engine
|
||||
*/
|
||||
export abstract class RustStrategy {
|
||||
protected name: string;
|
||||
protected id: string;
|
||||
protected parameters: Record<string, any>;
|
||||
protected positions: Map<string, number> = new Map();
|
||||
|
||||
constructor(name: string, id: string, parameters: Record<string, any> = {}) {
|
||||
this.name = name;
|
||||
this.id = id;
|
||||
this.parameters = parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main callback that Rust will call
|
||||
*/
|
||||
public handleCall(call: StrategyCall): StrategyResponse {
|
||||
switch (call.method) {
|
||||
case 'on_market_data':
|
||||
const signals = this.onMarketData(call.data);
|
||||
return { signals };
|
||||
|
||||
case 'on_fill':
|
||||
this.onFill(
|
||||
call.data.symbol,
|
||||
call.data.quantity,
|
||||
call.data.price,
|
||||
call.data.side
|
||||
);
|
||||
return { signals: [] };
|
||||
|
||||
default:
|
||||
return { signals: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when new market data is received
|
||||
*/
|
||||
protected abstract onMarketData(data: MarketData): Signal[];
|
||||
|
||||
/**
|
||||
* Called when an order is filled
|
||||
*/
|
||||
protected onFill(symbol: string, quantity: number, price: number, side: string): void {
|
||||
const currentPosition = this.positions.get(symbol) || 0;
|
||||
const newPosition = side === 'buy' ?
|
||||
currentPosition + quantity :
|
||||
currentPosition - quantity;
|
||||
|
||||
if (Math.abs(newPosition) < 0.0001) {
|
||||
this.positions.delete(symbol);
|
||||
} else {
|
||||
this.positions.set(symbol, newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a buy signal
|
||||
*/
|
||||
protected buySignal(symbol: string, strength: number = 1.0, reason?: string): Signal {
|
||||
return {
|
||||
symbol,
|
||||
signal_type: 'Buy',
|
||||
strength,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a sell signal
|
||||
*/
|
||||
protected sellSignal(symbol: string, strength: number = 1.0, reason?: string): Signal {
|
||||
return {
|
||||
symbol,
|
||||
signal_type: 'Sell',
|
||||
strength,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a close position signal
|
||||
*/
|
||||
protected closeSignal(symbol: string, reason?: string): Signal {
|
||||
return {
|
||||
symbol,
|
||||
signal_type: 'Close',
|
||||
strength: 1.0,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this strategy with a backtest engine
|
||||
*/
|
||||
public register(engine: BacktestEngine): void {
|
||||
console.log(`Registering strategy ${this.name} with id ${this.id}`);
|
||||
|
||||
// Convert the handleCall method to what Rust expects
|
||||
const callback = (callJson: string) => {
|
||||
console.log('Strategy callback called with:', callJson);
|
||||
const call: StrategyCall = JSON.parse(callJson);
|
||||
const response = this.handleCall(call);
|
||||
console.log('Strategy response:', response);
|
||||
return JSON.stringify(response);
|
||||
};
|
||||
|
||||
engine.addTypescriptStrategy(
|
||||
this.name,
|
||||
this.id,
|
||||
this.parameters,
|
||||
callback as any
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { RustStrategy, Signal } from '../RustStrategy';
|
||||
import { MarketData } from '../../types';
|
||||
|
||||
interface BarData {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class SimpleMovingAverageCrossoverRust extends RustStrategy {
|
||||
private priceHistory: Map<string, number[]> = new Map();
|
||||
private lastCrossover: Map<string, 'golden' | 'death' | null> = new Map();
|
||||
private barsSinceSignal: Map<string, number> = new Map();
|
||||
|
||||
private fastPeriod: number;
|
||||
private slowPeriod: number;
|
||||
private minHoldingBars: number;
|
||||
|
||||
constructor(parameters: {
|
||||
fastPeriod?: number;
|
||||
slowPeriod?: number;
|
||||
minHoldingBars?: number;
|
||||
} = {}) {
|
||||
super('SimpleMovingAverageCrossover', `sma-crossover-${Date.now()}`, parameters);
|
||||
|
||||
this.fastPeriod = parameters.fastPeriod || 5;
|
||||
this.slowPeriod = parameters.slowPeriod || 15;
|
||||
this.minHoldingBars = parameters.minHoldingBars || 3;
|
||||
}
|
||||
|
||||
protected onMarketData(data: MarketData): Signal[] {
|
||||
const signals: Signal[] = [];
|
||||
|
||||
// Only process bar data
|
||||
if (data.data.type !== 'bar') {
|
||||
return signals;
|
||||
}
|
||||
|
||||
const bar = data.data as BarData;
|
||||
const symbol = data.symbol;
|
||||
const price = bar.close;
|
||||
|
||||
// Update price history
|
||||
if (!this.priceHistory.has(symbol)) {
|
||||
this.priceHistory.set(symbol, []);
|
||||
this.barsSinceSignal.set(symbol, 0);
|
||||
}
|
||||
|
||||
const history = this.priceHistory.get(symbol)!;
|
||||
history.push(price);
|
||||
|
||||
// Keep only necessary history
|
||||
if (history.length > this.slowPeriod) {
|
||||
history.shift();
|
||||
}
|
||||
|
||||
// Update bars since signal
|
||||
const currentBar = this.barsSinceSignal.get(symbol) || 0;
|
||||
this.barsSinceSignal.set(symbol, currentBar + 1);
|
||||
|
||||
// Need enough data
|
||||
if (history.length < this.slowPeriod) {
|
||||
return signals;
|
||||
}
|
||||
|
||||
// Calculate moving averages
|
||||
const fastMA = this.calculateSMA(history, this.fastPeriod);
|
||||
const slowMA = this.calculateSMA(history, this.slowPeriod);
|
||||
|
||||
// Calculate previous MAs
|
||||
const prevHistory = history.slice(0, -1);
|
||||
const prevFastMA = this.calculateSMA(prevHistory, this.fastPeriod);
|
||||
const prevSlowMA = this.calculateSMA(prevHistory, this.slowPeriod);
|
||||
|
||||
// Check for crossovers
|
||||
const currentPosition = this.positions.get(symbol) || 0;
|
||||
const lastCrossover = this.lastCrossover.get(symbol);
|
||||
const barsSinceSignal = this.barsSinceSignal.get(symbol) || 0;
|
||||
|
||||
// Golden cross - bullish signal
|
||||
if (prevFastMA <= prevSlowMA && fastMA > slowMA) {
|
||||
this.lastCrossover.set(symbol, 'golden');
|
||||
|
||||
if (currentPosition < 0) {
|
||||
// Close short position
|
||||
signals.push(this.closeSignal(symbol, 'Golden cross - Close short'));
|
||||
} else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) {
|
||||
// Open long position
|
||||
signals.push(this.buySignal(symbol, 0.8, 'Golden cross - Open long'));
|
||||
this.barsSinceSignal.set(symbol, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Death cross - bearish signal
|
||||
else if (prevFastMA >= prevSlowMA && fastMA < slowMA) {
|
||||
this.lastCrossover.set(symbol, 'death');
|
||||
|
||||
if (currentPosition > 0) {
|
||||
// Close long position
|
||||
signals.push(this.closeSignal(symbol, 'Death cross - Close long'));
|
||||
} else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) {
|
||||
// Open short position
|
||||
signals.push(this.sellSignal(symbol, 0.8, 'Death cross - Open short'));
|
||||
this.barsSinceSignal.set(symbol, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Trend following - enter on pullbacks
|
||||
else if (currentPosition === 0 && barsSinceSignal >= this.minHoldingBars) {
|
||||
if (lastCrossover === 'golden' && fastMA > slowMA) {
|
||||
// Bullish trend continues
|
||||
signals.push(this.buySignal(symbol, 0.8, 'Bullish trend - Open long'));
|
||||
this.barsSinceSignal.set(symbol, 0);
|
||||
} else if (lastCrossover === 'death' && fastMA < slowMA) {
|
||||
// Bearish trend continues
|
||||
signals.push(this.sellSignal(symbol, 0.8, 'Bearish trend - Open short'));
|
||||
this.barsSinceSignal.set(symbol, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
private calculateSMA(prices: number[], period: number): number {
|
||||
if (prices.length < period) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const relevantPrices = prices.slice(-period);
|
||||
const sum = relevantPrices.reduce((a, b) => a + b, 0);
|
||||
return sum / period;
|
||||
}
|
||||
}
|
||||
145
apps/stock/orchestrator/test-rust-backtest.ts
Normal file
145
apps/stock/orchestrator/test-rust-backtest.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { createRustBacktest } from './src/backtest/RustBacktestEngine';
|
||||
import { SimpleMovingAverageCrossoverRust } from './src/strategies/rust/SimpleMovingAverageCrossoverRust';
|
||||
import { IServiceContainer } from '@stock-bot/di';
|
||||
|
||||
// Mock StorageService
|
||||
class MockStorageService {
|
||||
async getHistoricalBars(symbol: string, startDate: Date, endDate: Date, frequency: string) {
|
||||
// Generate mock data
|
||||
const bars = [];
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
let currentDate = new Date(startDate);
|
||||
let price = 100 + Math.random() * 50; // Start between 100-150
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
// Random walk
|
||||
const change = (Math.random() - 0.5) * 2; // +/- 1%
|
||||
price *= (1 + change / 100);
|
||||
|
||||
bars.push({
|
||||
symbol,
|
||||
timestamp: new Date(currentDate),
|
||||
open: price * (1 + (Math.random() - 0.5) * 0.01),
|
||||
high: price * (1 + Math.random() * 0.02),
|
||||
low: price * (1 - Math.random() * 0.02),
|
||||
close: price,
|
||||
volume: 1000000 + Math.random() * 500000,
|
||||
});
|
||||
|
||||
currentDate = new Date(currentDate.getTime() + msPerDay);
|
||||
}
|
||||
|
||||
return bars;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRustBacktest() {
|
||||
console.log('🚀 Testing Rust Backtest Engine with TypeScript Strategy\n');
|
||||
|
||||
// Create minimal container
|
||||
const container: IServiceContainer = {
|
||||
logger: {
|
||||
info: (msg: string, ...args: any[]) => console.log('[INFO]', msg, ...args),
|
||||
error: (msg: string, ...args: any[]) => console.error('[ERROR]', msg, ...args),
|
||||
warn: (msg: string, ...args: any[]) => console.warn('[WARN]', msg, ...args),
|
||||
debug: (msg: string, ...args: any[]) => console.log('[DEBUG]', msg, ...args),
|
||||
} as any,
|
||||
custom: {
|
||||
StorageService: new MockStorageService(),
|
||||
}
|
||||
};
|
||||
|
||||
// Backtest configuration
|
||||
const config = {
|
||||
mode: 'backtest' as const,
|
||||
name: 'Rust Engine Test',
|
||||
strategy: 'sma-crossover',
|
||||
symbols: ['AAPL'], // Just one symbol for testing
|
||||
startDate: '2023-01-01T00:00:00Z',
|
||||
endDate: '2023-01-31T00:00:00Z', // Just one month for testing
|
||||
initialCapital: 100000,
|
||||
commission: 0.001,
|
||||
slippage: 0.0001,
|
||||
dataFrequency: '1d',
|
||||
speed: 'max' as const,
|
||||
};
|
||||
|
||||
// Create strategy
|
||||
const strategy = new SimpleMovingAverageCrossoverRust({
|
||||
fastPeriod: 10,
|
||||
slowPeriod: 30,
|
||||
minHoldingBars: 5,
|
||||
});
|
||||
|
||||
console.log('Configuration:');
|
||||
console.log(` Symbols: ${config.symbols.join(', ')}`);
|
||||
console.log(` Period: ${config.startDate} to ${config.endDate}`);
|
||||
console.log(` Initial Capital: $${config.initialCapital.toLocaleString()}`);
|
||||
console.log(` Strategy: ${strategy.constructor.name}`);
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
console.log('Running backtest in Rust engine...\n');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await createRustBacktest(container, config, [strategy]);
|
||||
console.log('Raw result:', result);
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
console.log(`\n✅ Backtest completed in ${duration.toFixed(2)} seconds`);
|
||||
|
||||
if (!result || !result.metrics) {
|
||||
console.error('Invalid result structure:', result);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== PERFORMANCE METRICS ===');
|
||||
console.log(`Total Return: ${result.metrics.totalReturn?.toFixed(2) || 'N/A'}%`);
|
||||
console.log(`Sharpe Ratio: ${result.metrics.sharpeRatio?.toFixed(2) || 'N/A'}`);
|
||||
console.log(`Max Drawdown: ${result.metrics.maxDrawdown ? (result.metrics.maxDrawdown * 100).toFixed(2) : 'N/A'}%`);
|
||||
console.log(`Win Rate: ${result.metrics.winRate?.toFixed(1) || 'N/A'}%`);
|
||||
console.log(`Total Trades: ${result.metrics.totalTrades || 0}`);
|
||||
console.log(`Profit Factor: ${result.metrics.profitFactor?.toFixed(2) || 'N/A'}`);
|
||||
|
||||
console.log('\n=== TRADE STATISTICS ===');
|
||||
console.log(`Profitable Trades: ${result.metrics.profitableTrades || 0}`);
|
||||
console.log(`Average Win: $${result.metrics.avgWin?.toFixed(2) || '0.00'}`);
|
||||
console.log(`Average Loss: $${result.metrics.avgLoss?.toFixed(2) || '0.00'}`);
|
||||
console.log(`Total P&L: $${result.metrics.totalPnl?.toFixed(2) || '0.00'}`);
|
||||
|
||||
console.log('\n=== EQUITY CURVE ===');
|
||||
if (result.equityCurve.length > 0) {
|
||||
const firstValue = result.equityCurve[0].value;
|
||||
const lastValue = result.equityCurve[result.equityCurve.length - 1].value;
|
||||
console.log(`Starting Value: $${firstValue.toLocaleString()}`);
|
||||
console.log(`Ending Value: $${lastValue.toLocaleString()}`);
|
||||
console.log(`Growth: ${((lastValue / firstValue - 1) * 100).toFixed(2)}%`);
|
||||
}
|
||||
|
||||
console.log('\n=== FINAL POSITIONS ===');
|
||||
const positions = Object.entries(result.finalPositions);
|
||||
if (positions.length > 0) {
|
||||
for (const [symbol, position] of positions) {
|
||||
console.log(`${symbol}: ${position.quantity} shares @ $${position.averagePrice}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No open positions');
|
||||
}
|
||||
|
||||
// Compare with TypeScript engine performance
|
||||
console.log('\n=== PERFORMANCE COMPARISON ===');
|
||||
console.log('TypeScript Engine: ~5-10 seconds for 1 year backtest');
|
||||
console.log(`Rust Engine: ${duration.toFixed(2)} seconds`);
|
||||
console.log(`Speed Improvement: ${(10 / duration).toFixed(1)}x faster`);
|
||||
} catch (innerError) {
|
||||
console.error('Result processing error:', innerError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Backtest failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testRustBacktest().catch(console.error);
|
||||
Loading…
Add table
Add a link
Reference in a new issue