use std::sync::Arc;
use async_trait::async_trait;
use rustrade_backtest::{Backtest, BacktestConfig};
use rustrade_core::{
Brain, BrainHealth, Candle, Decision, MarketDataEvent, Position, Result as CoreResult,
};
use rustrade_risk::{CircuitBreakerConfig, SessionPnlConfig, SizingConfig};
struct ChurnBrain;
#[async_trait]
impl Brain for ChurnBrain {
fn name(&self) -> &str {
"churn"
}
async fn on_event(&self, _e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
if p.qty == 0.0 {
Ok(Decision::buy(1.0))
} else {
Ok(Decision::close())
}
}
async fn health(&self) -> BrainHealth {
BrainHealth::ok()
}
}
fn down_series(n: usize, start: f64, t0_ms: i64, step_ms: i64) -> Vec<Candle> {
(0..n)
.map(|i| {
let p = start - i as f64;
Candle {
time: t0_ms + i as i64 * step_ms,
open: p,
high: p,
low: p,
close: p,
volume: 1.0,
}
})
.collect()
}
fn base_cfg() -> rustrade_backtest::BacktestConfigBuilder {
BacktestConfig::builder()
.symbol("BTCUSDT")
.initial_cash(100_000.0)
.sizing(SizingConfig {
margin_per_trade: 1_000.0,
leverage: 1,
max_contracts: 100,
})
.fees(rustrade_backtest::FeeModel::Zero)
}
#[tokio::test]
async fn session_halt_stops_trading_at_the_loss_limit() {
let gated = Backtest::new(
base_cfg()
.session_pnl(SessionPnlConfig { loss_limit: -25.0 })
.build()
.unwrap(),
Arc::new(ChurnBrain),
)
.with_candles(down_series(20, 100.0, 0, 60_000))
.run()
.await
.unwrap();
assert_eq!(
gated.trades.len(),
3,
"the 3rd loss crosses -25 and halts: {:#?}",
gated.trades
);
assert!(
gated.orders_blocked > 0,
"post-halt decisions must be counted as blocked"
);
assert!(
gated.net_pnl <= -25.0 && gated.net_pnl > -45.0,
"loss must stop near the cap, got {}",
gated.net_pnl
);
let ungated = Backtest::new(base_cfg().build().unwrap(), Arc::new(ChurnBrain))
.with_candles(down_series(20, 100.0, 0, 60_000))
.run()
.await
.unwrap();
assert!(ungated.trades.len() > gated.trades.len());
assert_eq!(
ungated.orders_blocked, 0,
"no gates configured → never blocked"
);
assert!(
ungated.net_pnl < gated.net_pnl,
"the gate must cap the bleed"
);
}
#[tokio::test]
async fn session_halt_rolls_over_at_utc_midnight_in_replay_time() {
const DAY_MS: i64 = 86_400_000;
let mut candles = down_series(6, 100.0, DAY_MS - 6 * 60_000, 60_000); candles.extend(down_series(6, 94.0, DAY_MS + 60_000, 60_000));
let result = Backtest::new(
base_cfg()
.session_pnl(SessionPnlConfig { loss_limit: -15.0 })
.build()
.unwrap(),
Arc::new(ChurnBrain),
)
.with_candles(candles)
.run()
.await
.unwrap();
assert!(
result.trades.len() >= 3,
"day-2 candles must trade after the UTC rollover: {:#?}",
result.trades
);
assert!(
result.orders_blocked > 0,
"day-1 tail must have been blocked"
);
let last_close = result.trades.last().unwrap().closed_at.timestamp_millis();
assert!(
last_close >= DAY_MS,
"the last trade must come from day 2 (got t={last_close})"
);
}
#[tokio::test]
async fn circuit_breaker_trips_then_resumes_after_cooldown() {
let mut candles = down_series(8, 100.0, 0, 60_000); candles.extend(down_series(4, 90.0, 2 * 3_600_000, 60_000));
let result = Backtest::new(
base_cfg()
.circuit_breaker(CircuitBreakerConfig {
loss_limit: 2,
window_secs: 86_400,
cooldown_secs: 3_600,
})
.build()
.unwrap(),
Arc::new(ChurnBrain),
)
.with_candles(candles)
.run()
.await
.unwrap();
assert!(
result.orders_blocked > 0,
"decisions while tripped must be blocked"
);
assert!(
result.trades.len() > 2,
"trading must resume after the cooldown: {:#?}",
result.trades
);
let last_close = result.trades.last().unwrap().closed_at.timestamp_millis();
assert!(
last_close >= 2 * 3_600_000,
"the last trade must come from the post-cooldown window (got t={last_close})"
);
}
#[tokio::test]
async fn gates_block_close_decisions_too_matching_live() {
struct PartialThenClose;
#[async_trait]
impl Brain for PartialThenClose {
fn name(&self) -> &str {
"partial-then-close"
}
async fn on_event(&self, e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
let MarketDataEvent::Candle { candle, .. } = e else {
return Ok(Decision::hold());
};
match candle.time / 60_000 {
0 => Ok(Decision::buy(1.0)),
1 => Ok(
Decision::sell(1.0).with_size_hint(rustrade_core::SizeHint::Quantity(
rustrade_core::Volume(5.0),
)),
),
_ if p.qty != 0.0 => Ok(Decision::close()),
_ => Ok(Decision::hold()),
}
}
async fn health(&self) -> BrainHealth {
BrainHealth::ok()
}
}
let result = Backtest::new(
base_cfg()
.session_pnl(SessionPnlConfig { loss_limit: -5.0 })
.build()
.unwrap(),
Arc::new(PartialThenClose),
)
.with_candles(down_series(8, 100.0, 0, 60_000))
.run()
.await
.unwrap();
assert_eq!(result.trades.len(), 1, "{:#?}", result.trades);
assert_eq!(result.trades[0].qty, 5.0);
assert!(
result.orders_blocked >= 6,
"every post-halt Close must be blocked, got {}",
result.orders_blocked
);
}
#[tokio::test]
async fn no_gates_configured_is_unchanged_and_never_blocks() {
let result = Backtest::new(base_cfg().build().unwrap(), Arc::new(ChurnBrain))
.with_candles(down_series(10, 100.0, 0, 60_000))
.run()
.await
.unwrap();
assert_eq!(result.orders_blocked, 0);
assert_eq!(result.trades.len(), 5, "5 uninterrupted round trips");
}