trade-halley / backtest_engine.py
Wanderhalleylee's picture
Update backtest_engine.py
79bc984 verified
# ============================================================
# backtest_engine.py — Trade Halley v3.1
# Motor de Backtest COMPLETO
# Correções: volume médio por trades, drawdown negativo,
# daily sem period/capital
# Prioridade de dados: Supabase → yfinance → BRAPI
# ============================================================
import pandas as pd
import numpy as np
import logging
from datetime import datetime, timedelta
logger = logging.getLogger("trade_halley.backtest")
# Mapeamento de tickers BMF para Yahoo Finance
BMF_TICKERS = {
"IBOV_FUT": "^BVSP",
"DOL_FUT": "USDBRL=X",
"SP500": "ES=F",
"NASDAQ": "NQ=F",
"DOW": "YM=F",
"CRUDE_OIL": "CL=F",
"GOLD": "GC=F",
"SILVER": "SI=F",
"EURO_FX": "EURUSD=X",
"BITCOIN": "BTC-USD",
}
def _load_data(ticker: str, period: str = "1y", interval: str = "1d",
start_date: str = None, end_date: str = None) -> pd.DataFrame:
"""
Carrega dados OHLCV para o ticker.
Tenta na ordem:
1. Supabase cache (via data_fetcher.get_stock_data_from_cache)
2. yfinance como fallback
3. BRAPI como último recurso
Retorna DataFrame com colunas: Open, High, Low, Close, Volume (index = DatetimeIndex).
"""
df = pd.DataFrame()
# --- Tentativa 1: Supabase cache (PRIORIDADE) ---
try:
from data_fetcher import get_stock_data_from_cache
timeframe = "daily" if interval in ("1d", "1wk", "1mo") else "intraday"
cached = get_stock_data_from_cache(ticker, timeframe=timeframe,
start_date=start_date, end_date=end_date)
if cached and len(cached) > 0:
df = pd.DataFrame(cached)
if "date" in df.columns:
df["date"] = pd.to_datetime(df["date"])
df.set_index("date", inplace=True)
elif "datetime" in df.columns:
df["datetime"] = pd.to_datetime(df["datetime"])
df.set_index("datetime", inplace=True)
logger.info(f"[supabase] {ticker} {interval}: {len(df)} registros")
except Exception as e:
logger.debug(f"Supabase cache miss para {ticker}: {e}")
# --- Tentativa 2: yfinance fallback ---
if df.empty:
try:
import yfinance as yf
yf_ticker = ticker
if ticker in BMF_TICKERS:
yf_ticker = BMF_TICKERS[ticker]
elif not ticker.endswith(".SA") and not any(c in ticker for c in ["^", "=", "-"]):
yf_ticker = f"{ticker}.SA"
if start_date and end_date:
data = yf.download(yf_ticker, start=start_date, end=end_date,
interval=interval, progress=False)
else:
data = yf.download(yf_ticker, period=period, interval=interval, progress=False)
if data is not None and not data.empty:
# Normaliza MultiIndex columns (yfinance >= 0.2.31)
if isinstance(data.columns, pd.MultiIndex):
data.columns = [col[0] if isinstance(col, tuple) else col for col in data.columns]
df = data.copy()
logger.info(f"[yfinance] {ticker} ({yf_ticker}) {interval}: {len(df)} registros")
except Exception as e:
logger.warning(f"yfinance falhou para {ticker}: {e}")
# --- Tentativa 3: BRAPI como último recurso ---
if df.empty:
try:
from data_fetcher import get_stock_data
raw = get_stock_data(ticker, period=period, interval=interval,
start_date=start_date, end_date=end_date)
if raw and len(raw) > 0:
df = pd.DataFrame(raw)
if "date" in df.columns:
df["date"] = pd.to_datetime(df["date"])
df.set_index("date", inplace=True)
elif "datetime" in df.columns:
df["datetime"] = pd.to_datetime(df["datetime"])
df.set_index("datetime", inplace=True)
logger.info(f"[brapi] {ticker} {interval}: {len(df)} registros")
except Exception as e:
logger.warning(f"BRAPI falhou para {ticker}: {e}")
if df.empty:
logger.error(f"Sem dados para {ticker} (period={period}, interval={interval})")
return pd.DataFrame()
# --- Normaliza colunas ---
col_map = {}
for col in df.columns:
cl = str(col).lower().strip()
if cl in ("open", "abertura"):
col_map[col] = "Open"
elif cl in ("high", "máxima", "maxima", "alta"):
col_map[col] = "High"
elif cl in ("low", "mínima", "minima", "baixa"):
col_map[col] = "Low"
elif cl in ("close", "fechamento", "adj close", "adj_close", "adjustedclose"):
col_map[col] = "Close"
elif cl in ("volume", "vol"):
col_map[col] = "Volume"
if col_map:
df.rename(columns=col_map, inplace=True)
for col in ["Open", "High", "Low", "Close", "Volume"]:
if col not in df.columns:
df[col] = 0.0
for col in ["Open", "High", "Low", "Close", "Volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0)
df = df[df["Close"] > 0]
if not isinstance(df.index, pd.DatetimeIndex):
try:
df.index = pd.to_datetime(df.index)
except Exception:
pass
df.sort_index(inplace=True)
df.dropna(subset=["Close"], inplace=True)
# Filtra por datas se fornecidas (caso os dados vieram do yfinance/BRAPI sem filtro)
if start_date:
try:
sd = pd.to_datetime(start_date)
df = df[df.index >= sd]
except Exception:
pass
if end_date:
try:
ed = pd.to_datetime(end_date)
df = df[df.index <= ed]
except Exception:
pass
return df
def _calc_metrics(trades: list, initial_capital: float, df: pd.DataFrame) -> dict:
"""
Calcula métricas no formato Trade Certo.
- resultado_pct: retorno composto (como o Trade Certo original)
- max_drawdown_pct: pior trade individual (valor negativo)
- ganho_medio_pct: total_return_pct / total_trades
- Volume médio: média de TODOS os dias do período (não só dos dias com trade)
"""
if not trades:
return {
"total_gain": 0, "pct_gain": 0.0,
"total_loss": 0, "pct_loss": 0.0,
"total_trades": 0, "resultado_pct": 0.0,
"max_drawdown_pct": 0.0, "ganho_maximo_pct": 0.0,
"ganho_medio_pct": 0.0, "volume_medio": 0.0,
"win_rate": 0.0, "profit_factor": 0.0,
"sharpe_ratio": 0.0, "sortino_ratio": 0.0,
"initial_capital": initial_capital,
"final_capital": initial_capital,
"total_return_pct": 0.0,
"avg_win": 0.0, "avg_loss": 0.0,
"gross_profit": 0.0, "gross_loss": 0.0,
"equity_curve": [initial_capital],
}
pnls = [t["pnl_pct"] for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p <= 0]
total_trades = len(trades)
total_gain = len(wins)
total_loss = len(losses)
pct_gain = (total_gain / total_trades * 100) if total_trades > 0 else 0.0
pct_loss = (total_loss / total_trades * 100) if total_trades > 0 else 0.0
gross_profit = sum(wins) if wins else 0.0
gross_loss = sum(losses) if losses else 0.0
avg_win = np.mean(wins) if wins else 0.0
avg_loss = np.mean(losses) if losses else 0.0
ganho_maximo_pct = max(pnls) if pnls else 0.0
win_rate = pct_gain
profit_factor = abs(gross_profit / gross_loss) if gross_loss != 0 else (
float("inf") if gross_profit > 0 else 0.0
)
# Equity curve (retorno composto)
equity = [initial_capital]
capital = initial_capital
for p in pnls:
capital = capital * (1 + p / 100.0)
equity.append(round(capital, 2))
final_capital = equity[-1]
total_return_pct = ((final_capital - initial_capital) / initial_capital * 100) if initial_capital > 0 else 0.0
# resultado_pct = retorno COMPOSTO (como o Trade Certo original)
resultado_pct = total_return_pct
# ← FIX 2: Max Drawdown = pior trade individual (valor negativo), como Trade Certo
max_drawdown_pct = min(pnls) if min(pnls) < 0 else 0.0 # ← FIX
# ← FIX 3: Ganho Médio = retorno composto / número de trades, como Trade Certo
ganho_medio_pct = total_return_pct / total_trades if total_trades > 0 else 0.0 # ← FIX
# Sharpe / Sortino
if len(pnls) > 1:
pnl_arr = np.array(pnls)
std_all = np.std(pnl_arr, ddof=1)
sharpe = (np.mean(pnl_arr) / std_all) if std_all > 0 else 0.0
neg_returns = pnl_arr[pnl_arr < 0]
std_neg = np.std(neg_returns, ddof=1) if len(neg_returns) > 1 else 0.0
sortino = (np.mean(pnl_arr) / std_neg) if std_neg > 0 else 0.0
else:
sharpe = 0.0
sortino = 0.0
# Volume médio — média de TODOS os dias do período
vol_medio = 0.0
if df is not None and "Volume" in df.columns and len(df) > 0:
vol_values = df["Volume"]
vol_values = vol_values[vol_values > 0]
if len(vol_values) > 0:
vol_medio = float(vol_values.mean())
return {
"total_gain": total_gain,
"pct_gain": round(pct_gain, 2),
"total_loss": total_loss,
"pct_loss": round(pct_loss, 2),
"total_trades": total_trades,
"resultado_pct": round(resultado_pct, 4),
"max_drawdown_pct": round(max_drawdown_pct, 4),
"ganho_maximo_pct": round(ganho_maximo_pct, 4),
"ganho_medio_pct": round(ganho_medio_pct, 4),
"volume_medio": round(vol_medio, 2),
"win_rate": round(win_rate, 2),
"profit_factor": round(profit_factor, 4) if profit_factor != float("inf") else 999.99,
"sharpe_ratio": round(sharpe, 4),
"sortino_ratio": round(sortino, 4),
"initial_capital": initial_capital,
"final_capital": round(final_capital, 2),
"total_return_pct": round(total_return_pct, 4),
"avg_win": round(avg_win, 4),
"avg_loss": round(avg_loss, 4),
"gross_profit": round(gross_profit, 4),
"gross_loss": round(gross_loss, 4),
"equity_curve": equity,
}
# ============================================================
# BACKTEST — INDICADORES TÉCNICOS (compatibilidade v2.2)
# ============================================================
def run_backtest(ticker: str, strategy_id: str, period: str = "1y",
interval: str = "1d", initial_capital: float = 10000.0,
stop_loss: float = None, take_profit: float = None) -> dict:
"""
Backtest de indicadores técnicos (SMA cross, RSI, MACD, etc.).
"""
from strategies import STRATEGIES, add_indicators, get_strategy_signal
if strategy_id not in STRATEGIES:
logger.error(f"Estratégia '{strategy_id}' não encontrada")
return None
df = _load_data(ticker, period, interval)
if df.empty:
logger.error(f"Sem dados para {ticker}")
return None
df = add_indicators(df)
signals = get_strategy_signal(df, strategy_id)
if signals is None or signals.empty:
logger.warning(f"Sem sinais para {ticker} com {strategy_id}")
return None
trades = []
in_position = False
entry_price = 0.0
entry_date = None
for i in range(1, len(df)):
sig = signals.iloc[i]
prev_sig = signals.iloc[i - 1]
row = df.iloc[i]
if not in_position:
if sig == 1 and prev_sig <= 0:
in_position = True
entry_price = row["Open"]
entry_date = df.index[i]
else:
if stop_loss and row["Low"] <= entry_price * (1 - stop_loss):
exit_price = entry_price * (1 - stop_loss)
pnl_pct = -stop_loss * 100
trades.append({
"entry_date": str(entry_date),
"entry_price": round(entry_price, 4),
"exit_date": str(df.index[i]),
"exit_price": round(exit_price, 4),
"pnl_pct": round(pnl_pct, 4),
"reason": "stop_loss",
})
in_position = False
continue
if take_profit and row["High"] >= entry_price * (1 + take_profit):
exit_price = entry_price * (1 + take_profit)
pnl_pct = take_profit * 100
trades.append({
"entry_date": str(entry_date),
"entry_price": round(entry_price, 4),
"exit_date": str(df.index[i]),
"exit_price": round(exit_price, 4),
"pnl_pct": round(pnl_pct, 4),
"reason": "take_profit",
})
in_position = False
continue
if sig <= 0 and prev_sig > 0:
exit_price = row["Open"]
pnl_pct = ((exit_price - entry_price) / entry_price) * 100
trades.append({
"entry_date": str(entry_date),
"entry_price": round(entry_price, 4),
"exit_date": str(df.index[i]),
"exit_price": round(exit_price, 4),
"pnl_pct": round(pnl_pct, 4),
"reason": "signal",
})
in_position = False
if in_position and len(df) > 0:
exit_price = df.iloc[-1]["Close"]
pnl_pct = ((exit_price - entry_price) / entry_price) * 100
trades.append({
"entry_date": str(entry_date),
"entry_price": round(entry_price, 4),
"exit_date": str(df.index[-1]),
"exit_price": round(exit_price, 4),
"pnl_pct": round(pnl_pct, 4),
"reason": "end_of_data",
})
strat_info = STRATEGIES[strategy_id]
metrics = _calc_metrics(trades, initial_capital, df)
return {
"ticker": ticker,
"strategy": strategy_id,
"strategy_name": strat_info["name"],
"period": period,
"interval": interval,
"data_points": len(df),
"start_date": str(df.index[0]) if len(df) > 0 else None,
"end_date": str(df.index[-1]) if len(df) > 0 else None,
"trades": trades,
"metrics": metrics,
}
def run_bulk_backtest(tickers: list, strategy_id: str, period: str = "1y",
interval: str = "1d", initial_capital: float = 10000.0) -> list:
"""Backtest de indicadores em múltiplos ativos."""
results = []
for ticker in tickers:
try:
r = run_backtest(
ticker=ticker, strategy_id=strategy_id,
period=period, interval=interval, initial_capital=initial_capital,
)
if r and r.get("metrics"):
m = r["metrics"]
results.append({
"ticker": ticker,
"strategy": strategy_id,
"total_trades": m["total_trades"],
"win_rate": m["win_rate"],
"total_return_pct": m["total_return_pct"],
"profit_factor": m["profit_factor"],
"max_drawdown_pct": m["max_drawdown_pct"],
"sharpe_ratio": m["sharpe_ratio"],
"final_capital": m["final_capital"],
})
except Exception as e:
logger.warning(f"Bulk skip {ticker}: {e}")
results.sort(key=lambda x: x.get("total_return_pct", 0), reverse=True)
return results
# ============================================================
# BACKTEST TRADE CERTO — DAILY
# ============================================================
def run_backtest_daily(ticker: str, entry_strategy_id: str,
exit_strategy_id: str, direction: str = "compra",
variation_pct: float = 0.0, period: str = "1y",
initial_capital: float = 10000.0,
start_date: str = None, end_date: str = None) -> dict:
"""
Backtest Trade Certo — Timeframe Diário.
"""
from strategies import ENTRY_STRATEGIES_DAILY, EXIT_STRATEGIES_DAILY
if entry_strategy_id not in ENTRY_STRATEGIES_DAILY:
logger.error(f"Entrada daily '{entry_strategy_id}' não encontrada")
return None
if exit_strategy_id not in EXIT_STRATEGIES_DAILY:
logger.error(f"Saída daily '{exit_strategy_id}' não encontrada")
return None
fetch_period = "1y"
if start_date and end_date:
try:
sd = pd.to_datetime(start_date)
ed = pd.to_datetime(end_date)
diff_days = (ed - sd).days
if diff_days <= 30:
fetch_period = "3mo"
elif diff_days <= 90:
fetch_period = "3mo"
elif diff_days <= 180:
fetch_period = "6mo"
else:
fetch_period = "1y"
except Exception:
fetch_period = "1y"
# Carrega dados SEM filtro de data (para ter o dia anterior ao start_date)
df = _load_data(ticker, fetch_period, interval="1d")
if df.empty:
logger.error(f"Sem dados diários para {ticker}")
return None
if len(df) < 2:
logger.error(f"Dados insuficientes para {ticker} (apenas {len(df)} registros)")
return None
df_full = df.copy()
if start_date:
try:
sd = pd.to_datetime(start_date)
df_full = df_full[df_full.index >= sd]
except Exception:
pass
if end_date:
try:
ed = pd.to_datetime(end_date)
df_full = df_full[df_full.index <= ed]
except Exception:
pass
if len(df_full) < 1:
logger.error(f"Nenhum dado no período para {ticker}")
return None
entry_strat = ENTRY_STRATEGIES_DAILY[entry_strategy_id]
exit_strat = EXIT_STRATEGIES_DAILY[exit_strategy_id]
entry_kwargs = {"direction": direction}
entry_requires = entry_strat.get("requires", [])
if "variation_pct" in entry_requires:
entry_kwargs["variation_pct"] = variation_pct
entry_func = entry_strat["function"]
entry_signals = entry_func(df, **entry_kwargs)
if entry_signals is None or entry_signals.empty:
logger.info(f"Nenhum sinal de entrada para {ticker} com {entry_strategy_id}")
metrics = _calc_metrics([], initial_capital, df_full)
return {
"ticker": ticker,
"entry_strategy": entry_strategy_id,
"entry_strategy_name": entry_strat["name"],
"exit_strategy": exit_strategy_id,
"exit_strategy_name": exit_strat["name"],
"direction": direction,
"variation_pct": variation_pct,
"period": period,
"start_date": str(df_full.index[0]) if len(df_full) > 0 else start_date,
"end_date": str(df_full.index[-1]) if len(df_full) > 0 else end_date,
"data_points": len(df_full),
"trades": [],
"metrics": metrics,
}
trades = []
exit_func = exit_strat["function"]
for i in range(len(df)):
idx = df.index[i]
if start_date:
try:
if idx < pd.to_datetime(start_date):
continue
except Exception:
pass
if end_date:
try:
if idx > pd.to_datetime(end_date):
continue
except Exception:
pass
if idx not in entry_signals.index:
continue
sig = entry_signals.loc[idx]
if isinstance(sig, pd.DataFrame):
sig = sig.iloc[0]
if not sig.get("entry", False):
continue
entry_price = sig.get("entry_price", np.nan)
if pd.isna(entry_price) or entry_price <= 0:
continue
try:
exit_result = exit_func(df, i, entry_price, direction=direction)
except Exception as e:
logger.warning(f"Exit error para {ticker} idx {i}: {e}")
continue
if exit_result is None:
continue
trades.append({
"entry_date": str(idx),
"exit_date": str(exit_result["exit_date"]),
"entry_price": round(float(entry_price), 4),
"exit_price": round(float(exit_result["exit_price"]), 4),
"pnl_pct": round(float(exit_result["pnl_pct"]), 4),
"direction": direction,
})
calc_capital = 10000.0
metrics = _calc_metrics(trades, calc_capital, df_full)
return {
"ticker": ticker,
"entry_strategy": entry_strategy_id,
"entry_strategy_name": entry_strat["name"],
"exit_strategy": exit_strategy_id,
"exit_strategy_name": exit_strat["name"],
"direction": direction,
"variation_pct": variation_pct,
"period": period,
"start_date": start_date or str(df_full.index[0]),
"end_date": end_date or str(df_full.index[-1]),
"data_points": len(df_full),
"trades": trades,
"metrics": metrics,
}
def run_bulk_backtest_daily(tickers: list, entry_strategy_id: str,
exit_strategy_id: str, direction: str = "compra",
variation_pct: float = 0.0, period: str = "1y",
initial_capital: float = 10000.0,
start_date: str = None, end_date: str = None) -> list:
"""Backtest Trade Certo Daily em múltiplos ativos."""
results = []
for ticker in tickers:
try:
r = run_backtest_daily(
ticker=ticker,
entry_strategy_id=entry_strategy_id,
exit_strategy_id=exit_strategy_id,
direction=direction,
variation_pct=variation_pct,
period=period,
initial_capital=initial_capital,
start_date=start_date,
end_date=end_date,
)
if r and r.get("metrics"):
m = r["metrics"]
results.append({
"acao": ticker,
"total_gain": m["total_gain"],
"pct_gain": m["pct_gain"],
"total_loss": m["total_loss"],
"pct_loss": m["pct_loss"],
"total_trades": m["total_trades"],
"resultado_pct": m["resultado_pct"],
"max_drawdown_pct": m["max_drawdown_pct"],
"ganho_maximo_pct": m["ganho_maximo_pct"],
"ganho_medio_pct": m["ganho_medio_pct"],
"volume_medio": m["volume_medio"],
"total_return_pct": m["total_return_pct"],
"win_rate": m["win_rate"],
"profit_factor": m["profit_factor"],
})
except Exception as e:
logger.warning(f"Bulk daily skip {ticker}: {e}")
results.sort(key=lambda x: x.get("resultado_pct", 0), reverse=True)
return results
# ============================================================
# BACKTEST TRADE CERTO — INTRADAY
# ============================================================
def _get_daily_reference(df_daily: pd.DataFrame, current_date) -> dict:
"""Obtém valores de referência do dia anterior."""
if df_daily is None or df_daily.empty:
return {"prev_close": None, "prev_open": None, "day_open": None}
if hasattr(current_date, 'date'):
target_date = current_date.date()
else:
target_date = current_date
prev_rows = df_daily[df_daily.index.date < target_date] if hasattr(df_daily.index, 'date') else df_daily[df_daily.index < str(target_date)]
prev_close = None
prev_open = None
if len(prev_rows) > 0:
prev_close = prev_rows.iloc[-1]["Close"]
prev_open = prev_rows.iloc[-1]["Open"]
day_rows = df_daily[df_daily.index.date == target_date] if hasattr(df_daily.index, 'date') else None
day_open = None
if day_rows is not None and len(day_rows) > 0:
day_open = day_rows.iloc[0]["Open"]
return {"prev_close": prev_close, "prev_open": prev_open, "day_open": day_open}
def run_backtest_intraday(ticker: str, entry_strategy_id: str,
exit_strategy_id: str, direction: str = "compra",
variation_pct: float = 0.0,
hour_start: str = "09:00", hour_end: str = "17:00",
hour_target: str = None,
target_pct: float = None,
stop_loss_pct: float = None,
close_eod: bool = True,
max_trades_per_day: int = 99,
bb_window: int = 20, bb_std: float = 2.0,
ma_period: int = 20,
period: str = "3mo",
initial_capital: float = 10000.0,
start_date: str = None,
end_date: str = None) -> dict:
"""Backtest Trade Certo — Intraday."""
from strategies import ENTRY_STRATEGIES_INTRADAY, EXIT_STRATEGIES_INTRADAY
if entry_strategy_id not in ENTRY_STRATEGIES_INTRADAY:
logger.error(f"Entrada intraday '{entry_strategy_id}' não encontrada")
return None
if exit_strategy_id not in EXIT_STRATEGIES_INTRADAY:
logger.error(f"Saída intraday '{exit_strategy_id}' não encontrada")
return None
df_intra = _load_data(ticker, period, interval="5m")
if df_intra.empty:
df_intra = _load_data(ticker, period, interval="15m")
if df_intra.empty:
df_intra = _load_data(ticker, period, interval="1h")
if df_intra.empty:
logger.error(f"Sem dados intraday para {ticker}")
return None
df_daily = _load_data(ticker, period, interval="1d")
if start_date:
try:
sd = pd.to_datetime(start_date)
df_intra = df_intra[df_intra.index >= sd]
except Exception:
pass
if end_date:
try:
ed = pd.to_datetime(end_date)
df_intra = df_intra[df_intra.index <= ed]
except Exception:
pass
if len(df_intra) < 2:
logger.error(f"Dados intraday insuficientes para {ticker}")
return None
if hasattr(df_intra.index, 'date'):
days = sorted(set(df_intra.index.date))
else:
days = [None]
entry_strat = ENTRY_STRATEGIES_INTRADAY[entry_strategy_id]
exit_strat = EXIT_STRATEGIES_INTRADAY[exit_strategy_id]
all_trades = []
for day in days:
if day is None:
day_df = df_intra
else:
day_mask = df_intra.index.date == day
day_df = df_intra[day_mask]
if day_df.empty:
continue
ref = _get_daily_reference(df_daily, day) if df_daily is not None and not df_daily.empty else {
"prev_close": None, "prev_open": None, "day_open": None
}
if ref["prev_close"] is None and day is not None:
prev_day_mask = df_intra.index.date < day
prev_data = df_intra[prev_day_mask]
if len(prev_data) > 0:
ref["prev_close"] = prev_data.iloc[-1]["Close"]
prev_days = sorted(set(prev_data.index.date))
if prev_days:
last_prev_day = prev_days[-1]
lpd_data = prev_data[prev_data.index.date == last_prev_day]
if len(lpd_data) > 0:
ref["prev_open"] = lpd_data.iloc[0]["Open"]
if ref["day_open"] is None and len(day_df) > 0:
ref["day_open"] = day_df.iloc[0]["Open"]
entry_kwargs = {"direction": direction}
entry_requires = entry_strat.get("requires", [])
fixed_params = entry_strat.get("params_fixed", {})
if "variation_pct" in entry_requires:
entry_kwargs["variation_pct"] = variation_pct
if "hour_start" in entry_requires:
entry_kwargs["hour_start"] = hour_start
if "hour_end" in entry_requires:
entry_kwargs["hour_end"] = hour_end
if "hour_target" in entry_requires:
entry_kwargs["hour_target"] = hour_target or hour_start
if "prev_close_value" in entry_requires:
entry_kwargs["prev_close_value"] = ref["prev_close"]
if "prev_open_value" in entry_requires:
entry_kwargs["prev_open_value"] = ref["prev_open"]
if "day_open_value" in entry_requires:
entry_kwargs["day_open_value"] = ref["day_open"]
if "bb_window" in entry_requires:
entry_kwargs["bb_window"] = bb_window
if "bb_std" in entry_requires:
entry_kwargs["bb_std"] = bb_std
if "ma_period" in entry_requires:
entry_kwargs["ma_period"] = ma_period
entry_kwargs.update(fixed_params)
entry_func = entry_strat["function"]
entry_signals = entry_func(day_df, **entry_kwargs)
if entry_signals is None or entry_signals.empty:
continue
day_trade_count = 0
for j in range(len(day_df)):
if day_trade_count >= max_trades_per_day:
break
idx = day_df.index[j]
if idx not in entry_signals.index:
continue
sig = entry_signals.loc[idx]
if isinstance(sig, pd.DataFrame):
sig = sig.iloc[0]
if not sig.get("entry", False):
continue
entry_price = sig.get("entry_price", np.nan)
if pd.isna(entry_price) or entry_price <= 0:
continue
try:
intra_idx = df_intra.index.get_loc(idx)
if isinstance(intra_idx, slice):
intra_idx = intra_idx.start
elif isinstance(intra_idx, np.ndarray):
intra_idx = int(np.where(intra_idx)[0][0])
except Exception:
continue
exit_kwargs = {"direction": direction}
exit_requires = exit_strat.get("requires", [])
exit_fixed = exit_strat.get("params_fixed", {})
if "target_pct" in exit_requires:
exit_kwargs["target_pct"] = target_pct or 1.0
if "stop_loss_pct" in exit_requires:
exit_kwargs["stop_loss_pct"] = stop_loss_pct
if "close_eod" in exit_requires:
exit_kwargs["close_eod"] = close_eod
if "hour_target" in exit_requires:
exit_kwargs["hour_target"] = hour_target or "17:00"
if "bb_window" in exit_requires:
exit_kwargs["bb_window"] = bb_window
if "bb_std" in exit_requires:
exit_kwargs["bb_std"] = bb_std
if "ma_period" in exit_requires:
exit_kwargs["ma_period"] = ma_period
exit_kwargs.update(exit_fixed)
try:
exit_func = exit_strat["function"]
exit_result = exit_func(df_intra, intra_idx, entry_price, **exit_kwargs)
except Exception as e:
logger.warning(f"Intraday exit error {ticker} idx {intra_idx}: {e}")
continue
if exit_result is None:
continue
all_trades.append({
"entry_date": str(idx),
"exit_date": str(exit_result["exit_date"]),
"entry_price": round(float(entry_price), 4),
"exit_price": round(float(exit_result["exit_price"]), 4),
"pnl_pct": round(float(exit_result["pnl_pct"]), 4),
"direction": direction,
"day": str(day),
})
day_trade_count += 1
metrics = _calc_metrics(all_trades, initial_capital, df_intra)
return {
"ticker": ticker,
"entry_strategy": entry_strategy_id,
"entry_strategy_name": entry_strat["name"],
"exit_strategy": exit_strategy_id,
"exit_strategy_name": exit_strat["name"],
"direction": direction,
"variation_pct": variation_pct,
"hour_start": hour_start,
"hour_end": hour_end,
"period": period,
"start_date": start_date or str(df_intra.index[0]),
"end_date": end_date or str(df_intra.index[-1]),
"data_points": len(df_intra),
"max_trades_per_day": max_trades_per_day,
"close_eod": close_eod,
"target_pct": target_pct,
"stop_loss_pct": stop_loss_pct,
"trades": all_trades,
"metrics": metrics,
}
# ============================================================
# BULK BACKTEST TRADE CERTO — INTRADAY
# ============================================================
def run_bulk_backtest_intraday(tickers: list, entry_strategy_id: str,
exit_strategy_id: str,
direction: str = "compra",
variation_pct: float = 0.0,
hour_start: str = "09:00",
hour_end: str = "17:00",
hour_target: str = None,
target_pct: float = None,
stop_loss_pct: float = None,
close_eod: bool = True,
max_trades_per_day: int = 99,
bb_window: int = 20, bb_std: float = 2.0,
ma_period: int = 20,
period: str = "3mo",
initial_capital: float = 10000.0,
start_date: str = None,
end_date: str = None) -> list:
"""Backtest Trade Certo Intraday em múltiplos ativos."""
results = []
for ticker in tickers:
try:
r = run_backtest_intraday(
ticker=ticker,
entry_strategy_id=entry_strategy_id,
exit_strategy_id=exit_strategy_id,
direction=direction,
variation_pct=variation_pct,
hour_start=hour_start,
hour_end=hour_end,
hour_target=hour_target,
target_pct=target_pct,
stop_loss_pct=stop_loss_pct,
close_eod=close_eod,
max_trades_per_day=max_trades_per_day,
bb_window=bb_window,
bb_std=bb_std,
ma_period=ma_period,
period=period,
initial_capital=initial_capital,
start_date=start_date,
end_date=end_date,
)
if r and r.get("metrics"):
m = r["metrics"]
results.append({
"acao": ticker,
"total_gain": m["total_gain"],
"pct_gain": m["pct_gain"],
"total_loss": m["total_loss"],
"pct_loss": m["pct_loss"],
"total_trades": m["total_trades"],
"resultado_pct": m["resultado_pct"],
"max_drawdown_pct": m["max_drawdown_pct"],
"ganho_maximo_pct": m["ganho_maximo_pct"],
"ganho_medio_pct": m["ganho_medio_pct"],
"volume_medio": m["volume_medio"],
"total_return_pct": m["total_return_pct"],
"win_rate": m["win_rate"],
"profit_factor": m["profit_factor"],
})
except Exception as e:
logger.warning(f"Bulk intraday skip {ticker}: {e}")
results.sort(key=lambda x: x.get("resultado_pct", 0), reverse=True)
return results