diff --git a/scripts/portfolio_sim.py b/scripts/portfolio_sim.py new file mode 100644 index 0000000..b6feb73 --- /dev/null +++ b/scripts/portfolio_sim.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Portfolio simulator — combines trade logs from multiple Ares backtest runs +into a single compounding account, sorted chronologically by open time. + +Usage: python3 scripts/portfolio_sim.py < combined_trades.txt +Env: STOP_OUT_PCT=0.0 (fraction of initial balance, e.g. 0.2 = stop at $120 on $600) +""" + +import sys +import os +import re +from decimal import Decimal + +TRADE_RE = re.compile( + r'\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}) \S+\].*?pnl=([+-]?\d+\.\d+) bal=(\d+\.\d+)' +) + +def parse_trades(lines): + trades = [] + for line in lines: + m = TRADE_RE.search(line) + if not m: + continue + open_time = m.group(1) + pnl_abs = Decimal(m.group(2)) + bal_after = Decimal(m.group(3)) + bal_before = bal_after - pnl_abs + if bal_before <= 0: + continue + pnl_ratio = pnl_abs / bal_before + trades.append((open_time, pnl_ratio)) + return trades + +def simulate(trades, start_balance=Decimal("600"), stop_out_pct=Decimal("0.0")): + trades_sorted = sorted(trades, key=lambda t: t[0]) + stop_out_bal = start_balance * stop_out_pct + + balance = start_balance + peak = balance + max_dd = Decimal("0") + wins = losses = 0 + margin_called = False + + for open_time, pnl_ratio in trades_sorted: + if balance <= stop_out_bal: + print(f" *** MARGIN CALL at {open_time}: balance ${balance:.2f} ≤ stop-out ${stop_out_bal:.2f} ***") + margin_called = True + break + + pnl = balance * pnl_ratio + balance += pnl + if balance > peak: + peak = balance + dd = balance - peak + if dd < max_dd: + max_dd = dd + if pnl >= 0: + wins += 1 + else: + losses += 1 + + total = wins + losses + wr = wins / total * 100 if total else 0 + ret = (balance - start_balance) / start_balance * 100 + + print("─" * 47) + print(f" Combined Portfolio (all pairs)") + print("─" * 47) + print(f" Stop-out level : {float(stop_out_pct)*100:.0f}% of initial (${stop_out_bal:.2f})") + print(f" Trades : {total} (W={wins} L={losses} WR={wr:.1f}%)") + print(f" Start balance : ${start_balance:,.2f}") + print(f" Final balance : ${balance:,.2f}") + print(f" Total return : {ret:+,.1f}%") + print(f" Max drawdown : ${max_dd:,.2f} ({float(max_dd/peak)*100:.1f}% of peak)") + if margin_called: + print(f" *** MARGIN CALL triggered ***") + print("─" * 47) + +if __name__ == "__main__": + lines = sys.stdin.readlines() + trades = parse_trades(lines) + start_bal = Decimal(os.environ.get("BACKTEST_BALANCE", "600")) + stop_out_pct = Decimal(os.environ.get("STOP_OUT_PCT", "0.0")) + if not trades: + print("No trades found in input.") + sys.exit(1) + simulate(trades, start_balance=start_bal, stop_out_pct=stop_out_pct) diff --git a/src/main.rs b/src/main.rs index b9a302d..d7adba2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,11 @@ async fn main() -> anyhow::Result<()> { Err(_) => None, }; + // Stop-out level as fraction of initial balance (0.0 = Exness default: equity hits $0). + // e.g. STOP_OUT_PCT=0.2 stops trading when equity drops to 20% of starting balance. + let stop_out_pct: Decimal = std::env::var("STOP_OUT_PCT") + .unwrap_or_else(|_| "0.0".to_string()).parse().context("STOP_OUT_PCT")?; + let timeframe = tf_str.parse::().map_err(|e| anyhow::anyhow!("{e}"))?; let mt5 = mt5_client::Mt5Client::new(mt5_base_url); @@ -164,11 +169,14 @@ async fn main() -> anyhow::Result<()> { tracing::info!(total, "starting walk-forward"); + let stop_out_balance = backtest_balance * stop_out_pct; + let mut balance = backtest_balance; let mut peak = balance; let mut max_drawdown = Decimal::ZERO; let mut open_trade: Option = None; let mut pending_fvg: Option = None; + let mut margin_called = false; let mut trades = 0u32; let mut wins = 0u32; @@ -217,6 +225,52 @@ async fn main() -> anyhow::Result<()> { ); continue; } + // ── stop-out check: worst-case equity on this candle ───────────── + if stop_out_pct > Decimal::ZERO { + let worst_price = match t.side { + Side::Long => candle.low, + Side::Short => candle.high, + }; + let pr_w = if profit_is_usd || worst_price <= Decimal::ZERO { + Decimal::ONE + } else { + Decimal::ONE / worst_price + }; + let unrealized_w = (match t.side { + Side::Long => (worst_price - t.actual_entry) * t.volume * contract_size, + Side::Short => (t.actual_entry - worst_price) * t.volume * contract_size, + }) * pr_w - commission_per_lot * t.volume; + if balance + unrealized_w <= stop_out_balance { + let t = open_trade.take().unwrap(); + let exit = actual_exit(t.side, worst_price, true, spread_price, slippage_price); + let commission = commission_per_lot * t.volume; + let pnl = (match t.side { + Side::Long => (exit - t.actual_entry) * t.volume * contract_size, + Side::Short => (t.actual_entry - exit) * t.volume * contract_size, + }) * pr_w - commission; + balance += pnl; + if balance > peak { peak = balance; } + let dd = balance - peak; + if dd < max_drawdown { max_drawdown = dd; } + trades += 1; + losses += 1; + sum_losses += pnl.abs(); + cur_consec += 1; + if cur_consec > max_consec { max_consec = cur_consec; } + total_pnl += pnl; + println!( + "[{} {}] {} {} entry={} → STOP-OUT exit={} pnl={} bal={:.2}", + t.open_time, tf_str, symbol, + if t.side == Side::Long { "LONG " } else { "SHORT" }, + fmt_price(t.actual_entry, prec), + fmt_price(exit, prec), + fmt_pnl(pnl), balance, + ); + margin_called = true; + break; + } + } + let (sl_hit, tp_hit) = match t.side { Side::Long => (candle.low <= t.sl, candle.high >= t.tp), Side::Short => (candle.high >= t.sl, candle.low <= t.tp), @@ -335,6 +389,12 @@ async fn main() -> anyhow::Result<()> { continue; } + if balance <= stop_out_balance { + pending_fvg = None; + margin_called = true; + break; + } + let value_per_lot = if profit_is_usd || candle.close == Decimal::ZERO { contract_size } else { @@ -443,6 +503,9 @@ async fn main() -> anyhow::Result<()> { println!("Max Drawdown : {max_drawdown:.2}"); println!("Return : {ret_pct:.1}%"); println!("Final Balance : {balance:.2}"); + if margin_called { + println!("*** MARGIN CALL — stop-out triggered at {:.1}% of initial balance ***", stop_out_pct * Decimal::from(100u32)); + } println!("─────────────────────────────────────────"); Ok(())