use clap::Parser;
use backtest_server::rpc_types::*;
use qs_backtest_api::provider::xrpc::BacktestXrpcClient;
use qs_backtest_api::{BacktestClient, BacktestDiscoveryClient, BacktestSyncClient};
use qs_service::ServiceEndpoint;
use qs_service_xrpc::XrpcTransportConfig;
#[derive(Parser, Debug)]
#[command(
name = "backtest-client",
about = "Example client for the backtest service"
)]
struct Args {
#[arg(long, default_value = "backtest")]
shm_name: String,
#[arg(long)]
endpoint: Option<ServiceEndpoint>,
#[arg(long, default_value = "EURUSD")]
symbol: String,
#[arg(long, default_value = "icmarkets")]
exchange: String,
#[arg(long, default_value = "tick")]
data_type: String,
#[arg(long)]
timeframe: Option<String>,
#[arg(long)]
from: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long, default_value_t = 10_000.0)]
balance: f64,
}
async fn connect(
endpoint: &ServiceEndpoint,
client_name: &str,
) -> Result<BacktestXrpcClient, Box<dyn std::error::Error>> {
println!(" Connecting to {endpoint} ...");
Ok(BacktestXrpcClient::connect(endpoint, client_name, &XrpcTransportConfig::default()).await?)
}
fn print_header(title: &str) {
println!();
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ {:<59}║", title);
println!("╚══════════════════════════════════════════════════════════════╝");
}
fn print_section(title: &str) {
println!();
println!("── {} ──────────────────────────────────────────", title);
}
fn print_result_summary(result: &BacktestResultMsg) {
print_section("Performance Summary");
println!(" Initial Balance: ${:>12.2}", result.initial_balance);
println!(" Final Balance: ${:>12.2}", result.final_balance);
println!(
" Total PnL: ${:>12.2} ({:>+.2}%)",
result.total_pnl,
(result.total_pnl / result.initial_balance) * 100.0
);
println!();
println!(" Total Trades: {:>6}", result.total_trades);
println!(" Winning: {:>6}", result.winning_trades);
println!(" Losing: {:>6}", result.losing_trades);
println!(" Win Rate: {:>6.1}%", result.win_rate * 100.0);
println!(" Profit Factor: {:>9.2}", result.profit_factor);
println!();
println!(
" Max Drawdown: ${:>12.2} ({:.2}%)",
result.max_drawdown, result.max_drawdown_pct
);
print_section("Position Summary");
println!(" Total Positions: {:>6}", result.total_positions);
println!(" Winning: {:>6}", result.winning_positions);
println!(" Losing: {:>6}", result.losing_positions);
println!(
" Position Win Rate: {:>6.1}%",
result.position_win_rate * 100.0
);
let rm = &result.risk_metrics;
print_section("Risk Metrics");
if let Some(sharpe) = rm.sharpe_ratio {
println!(" Sharpe Ratio: {:>9.3}", sharpe);
}
if let Some(sortino) = rm.sortino_ratio {
println!(" Sortino Ratio: {:>9.3}", sortino);
}
if let Some(calmar) = rm.calmar_ratio {
println!(" Calmar Ratio: {:>9.3}", calmar);
}
let st = &result.streaks;
print_section("Streak Stats");
println!(" Max Consec. Wins: {:>6}", st.max_consecutive_wins);
println!(" Max Consec. Losses:{:>6}", st.max_consecutive_losses);
println!(" Current Streak: {:>6}", st.current_streak);
print_section("Long vs Short");
println!(
" Long - trades: {}, pnl: ${:.2}, win rate: {:.1}%",
result.long_stats.total_trades,
result.long_stats.total_pnl,
result.long_stats.win_rate * 100.0
);
println!(
" Short - trades: {}, pnl: ${:.2}, win rate: {:.1}%",
result.short_stats.total_trades,
result.short_stats.total_pnl,
result.short_stats.win_rate * 100.0
);
if !result.per_close_reason.is_empty() {
print_section("Close Reasons");
for cr in &result.per_close_reason {
println!(
" {:<20} count={:<4} pnl=${:<10.2} avg=${:<10.2} ({:.1}%)",
cr.reason, cr.count, cr.total_pnl, cr.avg_pnl, cr.percentage
);
}
}
if !result.monthly_returns.is_empty() {
print_section("Monthly Returns");
let limit = result.monthly_returns.len().min(12);
for mr in &result.monthly_returns[..limit] {
println!(
" {}-{:02}: pnl=${:>10.2} trades={:<4} balance=${:.2}",
mr.year, mr.month, mr.pnl, mr.trade_count, mr.ending_balance
);
}
if result.monthly_returns.len() > limit {
println!(
" ... and {} more months",
result.monthly_returns.len() - limit
);
}
}
}
fn print_trade_log(trades: &[TradeResultMsg], max: usize) {
print_section("Trade Log");
if trades.is_empty() {
println!(" (no trades)");
return;
}
let show = trades.len().min(max);
println!(
" {:<12} {:<6} {:<6} {:>12} {:>12} {:>8} {:>12} {:<15}",
"POS_ID", "SYMBOL", "SIDE", "ENTRY", "EXIT", "EXEC_LOT", "PNL", "CLOSE_REASON"
);
println!(" {}", "-".repeat(100));
for t in &trades[..show] {
let pnl_marker = if t.pnl >= 0.0 { "+" } else { "" };
println!(
" {:<12} {:<6} {:<6} {:>12.5} {:>12.5} {:>8.4} {:>4}{:<8.2} {:<15}",
&t.position_id[..t.position_id.len().min(12)],
t.symbol,
t.side,
t.entry_price,
t.exit_price,
t.size,
pnl_marker,
t.pnl,
t.close_reason,
);
}
if trades.len() > show {
println!(" ... and {} more trades", trades.len() - show);
}
}
fn print_positions(positions: &[PositionSummaryMsg], max: usize) {
print_section("Position Summaries");
if positions.is_empty() {
println!(" (no positions)");
return;
}
let show = positions.len().min(max);
println!(
" {:<12} {:<6} {:<6} {:>12} {:>12} {:>8} {:>12} {:<20}",
"POS_ID", "SYMBOL", "SIDE", "ENTRY", "AVG_EXIT", "ORIG_LOT", "NET_PNL", "CLOSE_REASONS"
);
println!(" {}", "-".repeat(105));
for p in &positions[..show] {
let pnl_marker = if p.net_pnl >= 0.0 { "+" } else { "" };
let reasons = p.close_reasons.join(",");
println!(
" {:<12} {:<6} {:<6} {:>12.5} {:>12.5} {:>8.4} {:>4}{:<8.2} {:<20}",
&p.position_id[..p.position_id.len().min(12)],
p.symbol,
p.side,
p.entry_price,
p.avg_exit_price,
p.original_size,
pnl_marker,
p.net_pnl,
reasons,
);
}
if positions.len() > show {
println!(" ... and {} more positions", positions.len() - show);
}
}
fn generate_full_signal_actions(symbol: &str) -> Vec<RawSignalMsg> {
vec![
RawSignalMsg::Entry {
ts: "2024-02-01T09:00:00Z".into(),
symbol: symbol.into(),
side: "Buy".into(),
order_type: "Market".into(),
price: None,
risk: 1.0,
stoploss: Some(1.0800),
targets: vec![1.1050, 1.1100],
group: Some("lifecycle-demo".into()),
trade_id: Some("lifecycle-demo-buy-1".into()),
entry_class: None,
},
RawSignalMsg::ModifyStoploss {
ts: "2024-02-01T12:00:00Z".into(),
position: PositionRefMsg::ByTradeId {
trade_id: "lifecycle-demo-buy-1".into(),
},
price: 1.0850,
},
RawSignalMsg::ClosePartial {
ts: "2024-02-02T10:00:00Z".into(),
position: PositionRefMsg::ByTradeId {
trade_id: "lifecycle-demo-buy-1".into(),
},
ratio: 0.5,
},
RawSignalMsg::MoveStoplossToEntry {
ts: "2024-02-02T10:01:00Z".into(),
position: PositionRefMsg::ByTradeId {
trade_id: "lifecycle-demo-buy-1".into(),
},
},
RawSignalMsg::Entry {
ts: "2024-02-05T14:00:00Z".into(),
symbol: symbol.into(),
side: "Sell".into(),
order_type: "Market".into(),
price: None,
risk: 1.0,
stoploss: Some(1.1150),
targets: vec![1.0900],
group: Some("lifecycle-hedge".into()),
trade_id: Some("lifecycle-hedge-sell-1".into()),
entry_class: None,
},
RawSignalMsg::ScaleIn {
ts: "2024-02-06T09:30:00Z".into(),
position: PositionRefMsg::ByTradeId {
trade_id: "lifecycle-hedge-sell-1".into(),
},
price: None,
size: 0.25,
},
RawSignalMsg::AddRule {
ts: "2024-02-06T09:31:00Z".into(),
position: PositionRefMsg::ByTradeId {
trade_id: "lifecycle-hedge-sell-1".into(),
},
rule: RuleConfigDefMsg::TrailingStop { distance: 0.0050 },
},
RawSignalMsg::Entry {
ts: "2024-02-10T08:00:00Z".into(),
symbol: symbol.into(),
side: "Buy".into(),
order_type: "Market".into(),
price: None,
risk: 1.0,
stoploss: Some(1.0700),
targets: vec![1.1000, 1.1050],
group: Some("lifecycle-demo".into()),
trade_id: Some("lifecycle-demo-buy-2".into()),
entry_class: None,
},
RawSignalMsg::CloseAllInGroup {
ts: "2024-02-12T16:00:00Z".into(),
group_id: "lifecycle-hedge".into(),
},
RawSignalMsg::ModifyAllStoploss {
ts: "2024-02-14T10:00:00Z".into(),
symbol: symbol.into(),
price: 1.0900,
},
RawSignalMsg::CloseAll {
ts: "2024-02-15T17:00:00Z".into(),
},
]
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
print_header("Connecting to Backtest Server");
let endpoint = match args.endpoint.clone() {
Some(endpoint) => endpoint,
None => format!("shm://{}", args.shm_name).parse()?,
};
let client = connect(&endpoint, "example-backtest-client").await?;
let operation_result: Result<(), Box<dyn std::error::Error>> = async {
println!(" ✓ Connected successfully");
print_header("Ping");
let ping = client.ping().await?;
println!(" Status: {}", ping.status);
println!(" Uptime: {}s", ping.uptime_secs);
println!(" Data Dir: {}", ping.data_dir);
print_header("Available Profiles");
let profiles_resp = client.list_profiles().await?;
if profiles_resp.profiles.is_empty() {
println!(" (no profiles loaded on server)");
} else {
println!(
" {:<20} {:<15} {:<15} {:<12} {:<6}",
"NAME", "TARGETS", "RATIOS", "SL_MODE", "RULES"
);
println!(" {}", "-".repeat(70));
for p in &profiles_resp.profiles {
println!(
" {:<20} {:<15} {:<15} {:<12} {:<6}",
p.name,
format!("{:?}", p.use_targets),
format!("{:?}", p.close_ratios),
p.stoploss_mode,
p.rules_count,
);
}
}
print_header("Available Data");
let symbols_resp = client
.list_symbols(ListSymbolsRequest {
exchange: Some(args.exchange.clone()),
data_type: None,
})
.await?;
if symbols_resp.symbols.is_empty() {
println!(" (no data found for exchange '{}')", args.exchange);
} else {
let show_count = symbols_resp.symbols.len().min(20);
println!(
" Found {} datasets (showing first {}):",
symbols_resp.symbols.len(),
show_count
);
println!(
" {:<10} {:<10} {:<6} {:<6} {:>10} {:<22} {:<22}",
"EXCHANGE", "SYMBOL", "TYPE", "TF", "ROWS", "EARLIEST", "LATEST"
);
println!(" {}", "-".repeat(90));
for s in &symbols_resp.symbols[..show_count] {
println!(
" {:<10} {:<10} {:<6} {:<6} {:>10} {:<22} {:<22}",
s.exchange,
s.symbol,
s.data_type,
s.timeframe.as_deref().unwrap_or("-"),
s.row_count,
s.earliest,
s.latest,
);
}
if symbols_resp.symbols.len() > show_count {
println!(" ... and {} more", symbols_resp.symbols.len() - show_count);
}
}
print_header("Run Backtest - Full Signal Actions");
let raw_signals = generate_full_signal_actions(&args.symbol);
println!(
" Sending {} raw signals (entries + management) ...",
raw_signals.len()
);
print_section("Raw Signal Stream");
for (i, sig) in raw_signals.iter().enumerate() {
let desc = match sig {
RawSignalMsg::Entry { side, risk, .. } => {
format!("Entry {} risk={}", side, risk)
}
RawSignalMsg::ModifyStoploss { ts: _, price, .. } => {
format!("ModifyStoploss price={}", price)
}
RawSignalMsg::ClosePartial { ts: _, ratio, .. } => {
format!("ClosePartial ratio={}", ratio)
}
RawSignalMsg::MoveStoplossToEntry { .. } => "MoveStoplossToEntry".into(),
RawSignalMsg::ScaleIn { ts: _, size, .. } => {
format!("ScaleIn size={}", size)
}
RawSignalMsg::AddRule { rule, .. } => {
format!("AddRule {:?}", rule)
}
RawSignalMsg::CloseAllInGroup { group_id, .. } => {
format!("CloseAllInGroup {}", group_id)
}
RawSignalMsg::ModifyAllStoploss { ts: _, price, .. } => {
format!("ModifyAllStoploss price={}", price)
}
RawSignalMsg::CloseAll { .. } => "CloseAll".into(),
other => format!("{:?}", other),
};
let ts = match sig {
RawSignalMsg::Entry { ts, .. }
| RawSignalMsg::Close { ts, .. }
| RawSignalMsg::ClosePartial { ts, .. }
| RawSignalMsg::ModifyStoploss { ts, .. }
| RawSignalMsg::MoveStoplossToEntry { ts, .. }
| RawSignalMsg::AddTarget { ts, .. }
| RawSignalMsg::RemoveTarget { ts, .. }
| RawSignalMsg::ModifyTarget { ts, .. }
| RawSignalMsg::AddRule { ts, .. }
| RawSignalMsg::RemoveRule { ts, .. }
| RawSignalMsg::ScaleIn { ts, .. }
| RawSignalMsg::CancelPending { ts, .. }
| RawSignalMsg::CloseAllOf { ts, .. }
| RawSignalMsg::CloseAll { ts }
| RawSignalMsg::CancelAllPending { ts }
| RawSignalMsg::ModifyAllStoploss { ts, .. }
| RawSignalMsg::CloseAllInGroup { ts, .. }
| RawSignalMsg::ModifyAllStoplossInGroup { ts, .. } => ts.as_str(),
};
println!(" {:>2}. [{}] {}", i + 1, ts, desc);
}
let inline_profile = ManagementProfileMsg {
name: "inline-demo".into(),
target_selection: Some(TargetSelectionMsg::Selected(vec![1, 2])),
use_targets: vec![1, 2],
close_ratios: vec![0.5, 0.5],
target_source: None,
stoploss_mode: Some(StoplossModeMsg::FromSignal),
rules: vec![RuleConfigDefMsg::BreakevenAfterTargets { after_n: 1 }],
group_override: None,
let_remainder_run: false,
entry_geometry: None,
};
let raw_signal_request = BacktestRunSpec {
symbol: args.symbol.clone(),
symbols: Vec::new(),
all_symbols: false,
on_unavailable: Default::default(),
exchange: args.exchange.clone(),
data_type: args.data_type.clone(),
timeframe: args.timeframe.clone(),
from: args.from.clone(),
to: args.to.clone(),
raw_signals,
profile: None,
profile_def: Some(inline_profile),
entry_profile_routes: Vec::new(),
config: BacktestConfigMsg {
initial_balance: Some(args.balance),
close_on_finish: Some(true),
fill_model: Some("BidAsk".into()),
sizing: Some(SizingPolicyMsg::FixedLot { lots: 1.0 }),
costs: Default::default(),
},
};
let raw_signal_response = client
.run_backtest(RunBacktestRequest {
request: raw_signal_request,
future: FutureQuoteConfigMsg {
account_currency: "USD".into(),
..FutureQuoteConfigMsg::default()
},
evaluation: ProviderEvaluationOptionsMsg::default(),
result_delivery: ResultDeliveryMsg::Inline,
})
.await?;
println!(" Elapsed: {}ms", raw_signal_response.elapsed_ms);
if raw_signal_response.success {
if let Some(ref result) = raw_signal_response.result {
print_result_summary(result);
print_trade_log(&result.trade_log, 20);
print_positions(&result.positions, 10);
}
} else {
println!(
" ✗ Backtest failed: {}",
raw_signal_response
.error
.as_deref()
.unwrap_or("unknown error")
);
}
Ok(())
}
.await;
print_header("Done");
println!(" Closing connection...");
let shutdown_result: Result<(), Box<dyn std::error::Error>> = client
.close()
.await
.map_err(|error| Box::new(error) as Box<dyn std::error::Error>);
match (operation_result, shutdown_result) {
(Ok(()), Ok(())) => {
println!(" ✓ Client disconnected cleanly");
Ok(())
}
(Ok(()), Err(shutdown_error)) => Err(shutdown_error),
(Err(operation_error), Ok(())) => Err(operation_error),
(Err(operation_error), Err(shutdown_error)) => {
eprintln!(" Client shutdown also failed: {shutdown_error}");
Err(operation_error)
}
}
}