โ ะะฐะทะฐะด"""
Step 4: Backtest core โ single combo โ PnL.
Tests one parameter combination against cached kline data.
Usage:
import pickle
from backtest_core import run_backtest
with open("data_cache_7d.pkl", "rb") as f:
cache = pickle.load(f)
params = {
"z_entry": 2.0,
"z_max": 3.5,
"natr_min": 0.75,
"natr_max": 2.5,
"chop_min": 50,
"tp_pct": 2.0,
"sl_pct": 1.0,
}
result = run_backtest(cache["symbols"], params)
# result = {"trades": 42, "wins": 28, "losses": 14, "pnl": 12.5, "wr": 66.7, "pf": 2.1}
"""
import numpy as np
def run_backtest(symbols_data: dict, params: dict) -> dict:
"""
Run backtest for ONE parameter combination across all symbols.
Entry logic (per bar):
- |Z| > z_entry AND |Z| < z_max
- NATR >= natr_min AND NATR <= natr_max
- CHOP >= chop_min
- Z < 0 โ LONG, Z > 0 โ SHORT
Exit: TP% or SL% from entry price (whichever hits first on subsequent bars).
Uses HIGH/LOW of each bar to check SL/TP (realistic: intra-bar wicks).
Returns dict with trade stats.
"""
z_entry = params["z_entry"]
z_max = params["z_max"]
natr_min = params["natr_min"]
natr_max = params["natr_max"]
chop_min = params["chop_min"]
tp_pct = params["tp_pct"]
sl_pct = params["sl_pct"]
total_wins = 0
total_losses = 0
total_pnl = 0.0
gross_win = 0.0
gross_loss = 0.0
all_trades = []
for sym, d in symbols_data.items():
z = d["z"]
natr = d["natr"]
chop = d["chop"]
c = d["c"]
h = d["h"]
l = d["l"]
n = len(c)
i = 0
while i < n - 1:
# Check entry conditions at bar i
zi = z[i]
ni = natr[i]
ci_chop = chop[i]
if abs(zi) <= z_entry or abs(zi) >= z_max:
i += 1
continue
if ni < natr_min or ni > natr_max:
i += 1
continue
if ci_chop < chop_min:
i += 1
continue
# Determine side
if zi < 0:
side = "LONG"
else:
side = "SHORT"
entry_price = c[i]
if entry_price <= 0:
i += 1
continue
# Calculate TP/SL prices
if side == "LONG":
tp_price = entry_price * (1 + tp_pct / 100)
sl_price = entry_price * (1 - sl_pct / 100)
else:
tp_price = entry_price * (1 - tp_pct / 100)
sl_price = entry_price * (1 + sl_pct / 100)
# Scan forward for exit
exit_pnl = None
exit_bar = i + 1
for j in range(i + 1, n):
if side == "LONG":
# Check SL first (worst case โ wick hits both)
if l[j] <= sl_price:
exit_pnl = -sl_pct
exit_bar = j
break
if h[j] >= tp_price:
exit_pnl = tp_pct
exit_bar = j
break
else: # SHORT
if h[j] >= sl_price:
exit_pnl = -sl_pct
exit_bar = j
break
if l[j] <= tp_price:
exit_pnl = tp_pct
exit_bar = j
break
if exit_pnl is None:
# No exit found โ trade still open at end of data, skip
break
# Record trade
if exit_pnl > 0:
total_wins += 1
gross_win += exit_pnl
else:
total_losses += 1
gross_loss += abs(exit_pnl)
total_pnl += exit_pnl
# Cooldown: skip 6 bars (30 min on 5m) after exit to avoid re-entry
i = exit_bar + 6
continue
# end while for this symbol
total_trades = total_wins + total_losses
wr = (total_wins / total_trades * 100) if total_trades > 0 else 0
pf = (gross_win / gross_loss) if gross_loss > 0 else (999 if gross_win > 0 else 0)
return {
"trades": total_trades,
"wins": total_wins,
"losses": total_losses,
"pnl": round(total_pnl, 2),
"wr": round(wr, 1),
"pf": round(pf, 2),
"gross_win": round(gross_win, 2),
"gross_loss": round(gross_loss, 2),
}