2026-04-13 01:30:34 +02:00
|
|
|
from data.model import Trade
|
|
|
|
|
from collections import defaultdict
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_break_even_if_triggered(position, candle, strategy):
|
|
|
|
|
if not position:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not getattr(strategy, "use_break_even", False):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if position.get("break_even_armed"):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
trigger_rr = float(getattr(strategy, "be_trigger_rr", 1.0) or 0.0)
|
|
|
|
|
if trigger_rr <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
entry = position["entry_price"]
|
|
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 0.0)
|
|
|
|
|
if risk_distance <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
trigger_price = entry + (risk_distance * trigger_rr) if is_long else entry - (risk_distance * trigger_rr)
|
|
|
|
|
reached_trigger = candle.high >= trigger_price if is_long else candle.low <= trigger_price
|
|
|
|
|
if reached_trigger:
|
|
|
|
|
position["stop_loss"] = entry
|
|
|
|
|
position["break_even_armed"] = True
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 19:14:32 +02:00
|
|
|
def _apply_partial_tp_if_triggered(position, candle, strategy):
|
|
|
|
|
if not position:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not getattr(strategy, "use_partial_tp", False):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if position.get("partial_tp_taken"):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
trigger_rr = float(getattr(strategy, "partial_tp_rr", 1.0) or 0.0)
|
|
|
|
|
if trigger_rr <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
partial_pct = float(getattr(strategy, "partial_tp_percent", 0.0) or 0.0)
|
|
|
|
|
if partial_pct <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
entry = position["entry_price"]
|
|
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 0.0)
|
|
|
|
|
if risk_distance <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
trigger_price = entry + (risk_distance * trigger_rr) if is_long else entry - (risk_distance * trigger_rr)
|
|
|
|
|
reached_trigger = candle.high >= trigger_price if is_long else candle.low <= trigger_price
|
|
|
|
|
if not reached_trigger:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
lot_size = max(position.get("lot_size", 0.0), 0.0)
|
|
|
|
|
if lot_size <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
partial_pct = min(partial_pct, 100.0)
|
|
|
|
|
partial_lot = lot_size * (partial_pct / 100.0)
|
|
|
|
|
if partial_lot <= 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
price_move = (trigger_price - entry) if is_long else (entry - trigger_price)
|
|
|
|
|
partial_pnl = price_move * partial_lot
|
|
|
|
|
|
|
|
|
|
position["lot_size"] = max(lot_size - partial_lot, 0.0)
|
|
|
|
|
position["partial_tp_taken"] = True
|
|
|
|
|
position["partial_tp_realized_pnl"] = position.get("partial_tp_realized_pnl", 0.0) + partial_pnl
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
def run_backtest(candles, strategy, starting_balance, risk_reward=1.0,
|
|
|
|
|
max_daily_loss=0.0, max_consecutive_losses=0, risk_pct=1.0):
|
2026-04-09 15:58:19 +02:00
|
|
|
trades = []
|
|
|
|
|
position = None
|
2026-04-13 01:30:34 +02:00
|
|
|
consecutive_losses = 0
|
|
|
|
|
daily_pnl = defaultdict(float)
|
2026-05-11 19:14:32 +02:00
|
|
|
equity = float(starting_balance)
|
2026-04-11 17:23:26 +02:00
|
|
|
if hasattr(strategy, "prepare"):
|
|
|
|
|
strategy.prepare(candles)
|
2026-04-09 15:58:19 +02:00
|
|
|
|
|
|
|
|
for i, candle in enumerate(candles):
|
2026-04-13 01:30:34 +02:00
|
|
|
if position:
|
|
|
|
|
_apply_break_even_if_triggered(position, candle, strategy)
|
2026-05-11 19:14:32 +02:00
|
|
|
_apply_partial_tp_if_triggered(position, candle, strategy)
|
2026-04-13 01:30:34 +02:00
|
|
|
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
sl, tp = position["stop_loss"], position["take_profit"]
|
|
|
|
|
|
|
|
|
|
hit_sl = candle.low <= sl if is_long else candle.high >= sl
|
|
|
|
|
hit_tp = candle.high >= tp if is_long else candle.low <= tp
|
2026-04-11 17:23:26 +02:00
|
|
|
|
|
|
|
|
if hit_sl or hit_tp:
|
2026-04-13 01:30:34 +02:00
|
|
|
exit_price = sl if hit_sl else tp
|
|
|
|
|
price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price)
|
|
|
|
|
lot_size = max(position.get("lot_size", 0.0), 0.0)
|
2026-05-11 19:14:32 +02:00
|
|
|
partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0)
|
|
|
|
|
pnl = (price_move * lot_size) + partial_pnl
|
2026-04-13 20:37:21 +02:00
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 1e-12)
|
|
|
|
|
r_multiple = price_move / risk_distance
|
2026-04-11 17:23:26 +02:00
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
trades.append(Trade(
|
2026-04-11 17:23:26 +02:00
|
|
|
enter_time=position["enter_time"],
|
|
|
|
|
enter_price=position["entry_price"],
|
|
|
|
|
direction=position["direction"],
|
|
|
|
|
exit_time=candle.time_open,
|
|
|
|
|
exit_price=exit_price,
|
2026-04-13 01:30:34 +02:00
|
|
|
pnl=pnl,
|
|
|
|
|
r_multiple=r_multiple,
|
2026-05-11 19:14:32 +02:00
|
|
|
partial_tp_taken=bool(position.get("partial_tp_taken", False)),
|
|
|
|
|
partial_tp_realized_pnl=partial_pnl,
|
2026-04-13 01:30:34 +02:00
|
|
|
))
|
2026-04-11 17:23:26 +02:00
|
|
|
position = None
|
2026-05-11 19:14:32 +02:00
|
|
|
equity += pnl
|
2026-04-11 17:23:26 +02:00
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
if pnl <= 0:
|
|
|
|
|
consecutive_losses += 1
|
|
|
|
|
else:
|
|
|
|
|
consecutive_losses = 0
|
|
|
|
|
|
|
|
|
|
daily_pnl[candle.time_open.date()] += pnl
|
|
|
|
|
|
2026-04-11 17:23:26 +02:00
|
|
|
if position is None:
|
2026-05-11 19:14:32 +02:00
|
|
|
if equity <= 0:
|
|
|
|
|
continue
|
2026-04-13 01:30:34 +02:00
|
|
|
if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses:
|
|
|
|
|
continue
|
|
|
|
|
if max_daily_loss > 0:
|
|
|
|
|
loss_limit = starting_balance * (max_daily_loss / 100)
|
|
|
|
|
if daily_pnl[candle.time_open.date()] <= -loss_limit:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
signal = strategy.check_signal(candles, i)
|
2026-04-11 17:23:26 +02:00
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
if signal is not None:
|
|
|
|
|
is_long = signal.direction == "BUY"
|
|
|
|
|
entry = signal.entry_price
|
|
|
|
|
sl = signal.stop_loss
|
|
|
|
|
sl_distance = abs(entry - sl)
|
|
|
|
|
if (
|
|
|
|
|
sl_distance <= 0
|
|
|
|
|
or not math.isfinite(sl_distance)
|
|
|
|
|
or not math.isfinite(entry)
|
|
|
|
|
or not math.isfinite(sl)
|
|
|
|
|
or risk_pct <= 0
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
|
2026-05-11 19:14:32 +02:00
|
|
|
risk_amount = equity * (risk_pct / 100)
|
2026-04-13 01:30:34 +02:00
|
|
|
if risk_amount <= 0 or not math.isfinite(risk_amount):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
lot_size = risk_amount / sl_distance
|
|
|
|
|
if lot_size <= 0 or not math.isfinite(lot_size):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
tp = entry + (sl_distance * risk_reward) if is_long else entry - (sl_distance * risk_reward)
|
2026-04-11 17:23:26 +02:00
|
|
|
|
|
|
|
|
position = {
|
2026-04-13 01:30:34 +02:00
|
|
|
"direction": "long" if is_long else "short",
|
|
|
|
|
"entry_price": entry,
|
2026-04-11 17:23:26 +02:00
|
|
|
"enter_time": candle.time_open,
|
2026-04-13 01:30:34 +02:00
|
|
|
"stop_loss": sl,
|
|
|
|
|
"take_profit": tp,
|
|
|
|
|
"risk_distance": sl_distance,
|
|
|
|
|
"lot_size": lot_size,
|
|
|
|
|
"break_even_armed": False,
|
2026-05-11 19:14:32 +02:00
|
|
|
"partial_tp_taken": False,
|
|
|
|
|
"partial_tp_realized_pnl": 0.0,
|
2026-04-11 17:23:26 +02:00
|
|
|
}
|
2026-04-09 15:58:19 +02:00
|
|
|
|
2026-05-11 19:14:32 +02:00
|
|
|
if position and candles:
|
|
|
|
|
last_candle = candles[-1]
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
exit_price = last_candle.close
|
|
|
|
|
price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price)
|
|
|
|
|
lot_size = max(position.get("lot_size", 0.0), 0.0)
|
|
|
|
|
partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0)
|
|
|
|
|
pnl = (price_move * lot_size) + partial_pnl
|
|
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 1e-12)
|
|
|
|
|
r_multiple = price_move / risk_distance
|
|
|
|
|
|
|
|
|
|
trades.append(Trade(
|
|
|
|
|
enter_time=position["enter_time"],
|
|
|
|
|
enter_price=position["entry_price"],
|
|
|
|
|
direction=position["direction"],
|
|
|
|
|
exit_time=last_candle.time_open,
|
|
|
|
|
exit_price=exit_price,
|
|
|
|
|
pnl=pnl,
|
|
|
|
|
r_multiple=r_multiple,
|
|
|
|
|
partial_tp_taken=bool(position.get("partial_tp_taken", False)),
|
|
|
|
|
partial_tp_realized_pnl=partial_pnl,
|
|
|
|
|
))
|
|
|
|
|
equity += pnl
|
|
|
|
|
daily_pnl[last_candle.time_open.date()] += pnl
|
|
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
return trades
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_backtest_stream(candles, strategy, starting_balance, risk_reward=1.0,
|
|
|
|
|
max_daily_loss=0.0, max_consecutive_losses=0, risk_pct=1.0):
|
|
|
|
|
position = None
|
|
|
|
|
consecutive_losses = 0
|
|
|
|
|
daily_pnl = defaultdict(float)
|
|
|
|
|
total = len(candles)
|
2026-05-11 19:14:32 +02:00
|
|
|
equity = float(starting_balance)
|
2026-04-13 01:30:34 +02:00
|
|
|
|
|
|
|
|
if hasattr(strategy, "prepare"):
|
|
|
|
|
strategy.prepare(candles)
|
|
|
|
|
|
|
|
|
|
yield {"type": "start", "total_candles": total}
|
|
|
|
|
|
|
|
|
|
progress_interval = max(1, total // 50)
|
|
|
|
|
|
|
|
|
|
for i, candle in enumerate(candles):
|
|
|
|
|
if i % progress_interval == 0:
|
|
|
|
|
yield {"type": "progress", "processed_candles": i, "total_candles": total}
|
|
|
|
|
|
|
|
|
|
if position:
|
|
|
|
|
_apply_break_even_if_triggered(position, candle, strategy)
|
2026-05-11 19:14:32 +02:00
|
|
|
_apply_partial_tp_if_triggered(position, candle, strategy)
|
2026-04-13 01:30:34 +02:00
|
|
|
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
sl, tp = position["stop_loss"], position["take_profit"]
|
|
|
|
|
|
|
|
|
|
hit_sl = candle.low <= sl if is_long else candle.high >= sl
|
|
|
|
|
hit_tp = candle.high >= tp if is_long else candle.low <= tp
|
|
|
|
|
|
|
|
|
|
if hit_sl or hit_tp:
|
|
|
|
|
exit_price = sl if hit_sl else tp
|
|
|
|
|
price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price)
|
|
|
|
|
lot_size = max(position.get("lot_size", 0.0), 0.0)
|
2026-05-11 19:14:32 +02:00
|
|
|
partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0)
|
|
|
|
|
pnl = (price_move * lot_size) + partial_pnl
|
2026-04-13 20:37:21 +02:00
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 1e-12)
|
|
|
|
|
r_multiple = price_move / risk_distance
|
2026-04-13 01:30:34 +02:00
|
|
|
|
|
|
|
|
trade = Trade(
|
|
|
|
|
enter_time=position["enter_time"],
|
|
|
|
|
enter_price=position["entry_price"],
|
|
|
|
|
direction=position["direction"],
|
|
|
|
|
exit_time=candle.time_open,
|
|
|
|
|
exit_price=exit_price,
|
|
|
|
|
pnl=pnl,
|
|
|
|
|
r_multiple=r_multiple,
|
2026-05-11 19:14:32 +02:00
|
|
|
partial_tp_taken=bool(position.get("partial_tp_taken", False)),
|
|
|
|
|
partial_tp_realized_pnl=partial_pnl,
|
2026-04-13 01:30:34 +02:00
|
|
|
)
|
|
|
|
|
position = None
|
2026-05-11 19:14:32 +02:00
|
|
|
equity += pnl
|
2026-04-13 01:30:34 +02:00
|
|
|
|
|
|
|
|
if pnl <= 0:
|
|
|
|
|
consecutive_losses += 1
|
|
|
|
|
else:
|
|
|
|
|
consecutive_losses = 0
|
|
|
|
|
|
|
|
|
|
daily_pnl[candle.time_open.date()] += pnl
|
|
|
|
|
|
|
|
|
|
yield {"type": "trade", "trade": trade, "processed_candles": i, "total_candles": total}
|
|
|
|
|
|
|
|
|
|
if position is None:
|
2026-05-11 19:14:32 +02:00
|
|
|
if equity <= 0:
|
|
|
|
|
continue
|
2026-04-13 01:30:34 +02:00
|
|
|
if max_consecutive_losses > 0 and consecutive_losses >= max_consecutive_losses:
|
|
|
|
|
continue
|
|
|
|
|
if max_daily_loss > 0:
|
|
|
|
|
loss_limit = starting_balance * (max_daily_loss / 100)
|
|
|
|
|
if daily_pnl[candle.time_open.date()] <= -loss_limit:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
signal = strategy.check_signal(candles, i)
|
|
|
|
|
|
|
|
|
|
if signal is not None:
|
|
|
|
|
is_long = signal.direction == "BUY"
|
|
|
|
|
entry = signal.entry_price
|
|
|
|
|
sl = signal.stop_loss
|
|
|
|
|
sl_distance = abs(entry - sl)
|
|
|
|
|
if (
|
|
|
|
|
sl_distance <= 0
|
|
|
|
|
or not math.isfinite(sl_distance)
|
|
|
|
|
or not math.isfinite(entry)
|
|
|
|
|
or not math.isfinite(sl)
|
|
|
|
|
or risk_pct <= 0
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
|
2026-05-11 19:14:32 +02:00
|
|
|
risk_amount = equity * (risk_pct / 100)
|
2026-04-13 01:30:34 +02:00
|
|
|
if risk_amount <= 0 or not math.isfinite(risk_amount):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
lot_size = risk_amount / sl_distance
|
|
|
|
|
if lot_size <= 0 or not math.isfinite(lot_size):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
tp = entry + (sl_distance * risk_reward) if is_long else entry - (sl_distance * risk_reward)
|
2026-04-09 15:58:19 +02:00
|
|
|
|
2026-04-11 17:23:26 +02:00
|
|
|
position = {
|
2026-04-13 01:30:34 +02:00
|
|
|
"direction": "long" if is_long else "short",
|
|
|
|
|
"entry_price": entry,
|
2026-04-11 17:23:26 +02:00
|
|
|
"enter_time": candle.time_open,
|
2026-04-13 01:30:34 +02:00
|
|
|
"stop_loss": sl,
|
|
|
|
|
"take_profit": tp,
|
|
|
|
|
"risk_distance": sl_distance,
|
|
|
|
|
"lot_size": lot_size,
|
|
|
|
|
"break_even_armed": False,
|
2026-05-11 19:14:32 +02:00
|
|
|
"partial_tp_taken": False,
|
|
|
|
|
"partial_tp_realized_pnl": 0.0,
|
2026-04-11 17:23:26 +02:00
|
|
|
}
|
2026-04-09 15:58:19 +02:00
|
|
|
|
2026-05-11 19:14:32 +02:00
|
|
|
if position and candles:
|
|
|
|
|
last_candle = candles[-1]
|
|
|
|
|
is_long = position["direction"] == "long"
|
|
|
|
|
exit_price = last_candle.close
|
|
|
|
|
price_move = (exit_price - position["entry_price"]) if is_long else (position["entry_price"] - exit_price)
|
|
|
|
|
lot_size = max(position.get("lot_size", 0.0), 0.0)
|
|
|
|
|
partial_pnl = float(position.get("partial_tp_realized_pnl", 0.0) or 0.0)
|
|
|
|
|
pnl = (price_move * lot_size) + partial_pnl
|
|
|
|
|
risk_distance = max(position.get("risk_distance", 0.0), 1e-12)
|
|
|
|
|
r_multiple = price_move / risk_distance
|
|
|
|
|
|
|
|
|
|
trade = Trade(
|
|
|
|
|
enter_time=position["enter_time"],
|
|
|
|
|
enter_price=position["entry_price"],
|
|
|
|
|
direction=position["direction"],
|
|
|
|
|
exit_time=last_candle.time_open,
|
|
|
|
|
exit_price=exit_price,
|
|
|
|
|
pnl=pnl,
|
|
|
|
|
r_multiple=r_multiple,
|
|
|
|
|
partial_tp_taken=bool(position.get("partial_tp_taken", False)),
|
|
|
|
|
partial_tp_realized_pnl=partial_pnl,
|
|
|
|
|
)
|
|
|
|
|
equity += pnl
|
|
|
|
|
daily_pnl[last_candle.time_open.date()] += pnl
|
|
|
|
|
yield {"type": "trade", "trade": trade, "processed_candles": total, "total_candles": total}
|
|
|
|
|
|
2026-04-13 01:30:34 +02:00
|
|
|
yield {"type": "done", "total_candles": total}
|