mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-08-17 20:58:08 +00:00
feat: add margin call / stop-out simulation
STOP_OUT_PCT env var (default 0.0 = Exness standard: stop at $0 equity). - During open trade: checks worst-case equity (candle.low/high) each candle - Before new trade: checks balance > stop_out_balance - Prints STOP-OUT event in trade log and MARGIN CALL banner in summary Portfolio simulator updated: checks combined balance vs stop-out level. Findings: 1% risk — never margin-called at any stop-out level 5% XAUUSDm — MARGIN CALL at STOP_OUT_PCT=0.2 (balance hit $88.99 < $120) 5% XAGUSDm/BTC/Oil — survive 20% stop-out (lower early drawdown) Portfolio 4 pairs — no margin call at 20% stop-out (diversification) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e85f5a4322
commit
ddd8c98af5
@@ -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)
|
||||||
+63
@@ -133,6 +133,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Err(_) => None,
|
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::<domain::Timeframe>().map_err(|e| anyhow::anyhow!("{e}"))?;
|
let timeframe = tf_str.parse::<domain::Timeframe>().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
let mt5 = mt5_client::Mt5Client::new(mt5_base_url);
|
let mt5 = mt5_client::Mt5Client::new(mt5_base_url);
|
||||||
|
|
||||||
@@ -164,11 +169,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
tracing::info!(total, "starting walk-forward");
|
tracing::info!(total, "starting walk-forward");
|
||||||
|
|
||||||
|
let stop_out_balance = backtest_balance * stop_out_pct;
|
||||||
|
|
||||||
let mut balance = backtest_balance;
|
let mut balance = backtest_balance;
|
||||||
let mut peak = balance;
|
let mut peak = balance;
|
||||||
let mut max_drawdown = Decimal::ZERO;
|
let mut max_drawdown = Decimal::ZERO;
|
||||||
let mut open_trade: Option<OpenTrade> = None;
|
let mut open_trade: Option<OpenTrade> = None;
|
||||||
let mut pending_fvg: Option<detector::PendingFvg> = None;
|
let mut pending_fvg: Option<detector::PendingFvg> = None;
|
||||||
|
let mut margin_called = false;
|
||||||
|
|
||||||
let mut trades = 0u32;
|
let mut trades = 0u32;
|
||||||
let mut wins = 0u32;
|
let mut wins = 0u32;
|
||||||
@@ -217,6 +225,52 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
continue;
|
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 {
|
let (sl_hit, tp_hit) = match t.side {
|
||||||
Side::Long => (candle.low <= t.sl, candle.high >= t.tp),
|
Side::Long => (candle.low <= t.sl, candle.high >= t.tp),
|
||||||
Side::Short => (candle.high >= t.sl, candle.low <= t.tp),
|
Side::Short => (candle.high >= t.sl, candle.low <= t.tp),
|
||||||
@@ -335,6 +389,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
continue;
|
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 {
|
let value_per_lot = if profit_is_usd || candle.close == Decimal::ZERO {
|
||||||
contract_size
|
contract_size
|
||||||
} else {
|
} else {
|
||||||
@@ -443,6 +503,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
println!("Max Drawdown : {max_drawdown:.2}");
|
println!("Max Drawdown : {max_drawdown:.2}");
|
||||||
println!("Return : {ret_pct:.1}%");
|
println!("Return : {ret_pct:.1}%");
|
||||||
println!("Final Balance : {balance:.2}");
|
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!("─────────────────────────────────────────");
|
println!("─────────────────────────────────────────");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user