166 lines
5.4 KiB
Python
166 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
|
|
from .constants import TREND_BEAR, TREND_BULL
|
|
|
|
|
|
def _trade_return_pct(direction: str, entry_price: float, exit_price: float) -> float:
|
|
if direction == "LONG":
|
|
return ((exit_price - entry_price) / entry_price) * 100.0
|
|
return ((entry_price - exit_price) / entry_price) * 100.0
|
|
|
|
|
|
def backtest_signals(
|
|
df: pd.DataFrame,
|
|
slippage_bps: float = 0.0,
|
|
fee_bps: float = 0.0,
|
|
stop_loss_pct: float | None = None,
|
|
take_profit_pct: float | None = None,
|
|
max_hold_bars: int = 1,
|
|
min_hold_bars: int = 1,
|
|
) -> dict[str, float | int]:
|
|
if len(df) < 4:
|
|
return {"trades": 0, "wins": 0, "losses": 0, "win_rate": 0.0, "avg_pnl_pct": 0.0}
|
|
|
|
trend_series = df["trend_state"]
|
|
trend_change = trend_series != trend_series.shift(1)
|
|
signal_idx = df.index[trend_change & trend_series.isin([TREND_BULL, TREND_BEAR])]
|
|
|
|
wins = 0
|
|
losses = 0
|
|
pnl_values: list[float] = []
|
|
max_hold = max(1, int(max_hold_bars))
|
|
min_hold = max(1, min(int(min_hold_bars), max_hold))
|
|
total_cost_pct = ((float(slippage_bps) + float(fee_bps)) * 2.0) / 100.0
|
|
|
|
for idx in signal_idx:
|
|
pos = df.index.get_loc(idx)
|
|
if pos + 1 >= len(df):
|
|
continue
|
|
|
|
entry_close = float(df.iloc[pos]["Close"])
|
|
signal_trend = df.iloc[pos]["trend_state"]
|
|
direction = "LONG" if signal_trend == TREND_BULL else "SHORT"
|
|
|
|
exit_price = float(df.iloc[min(pos + max_hold, len(df) - 1)]["Close"])
|
|
for hold in range(min_hold, max_hold + 1):
|
|
exit_pos = min(pos + hold, len(df) - 1)
|
|
candidate_exit = float(df.iloc[exit_pos]["Close"])
|
|
raw_pnl = _trade_return_pct(direction, entry_close, candidate_exit)
|
|
if stop_loss_pct is not None and raw_pnl <= -float(stop_loss_pct):
|
|
exit_price = candidate_exit
|
|
break
|
|
if take_profit_pct is not None and raw_pnl >= float(take_profit_pct):
|
|
exit_price = candidate_exit
|
|
break
|
|
exit_price = candidate_exit
|
|
|
|
pnl_pct = _trade_return_pct(direction, entry_close, exit_price) - total_cost_pct
|
|
pnl_values.append(pnl_pct)
|
|
wins += int(pnl_pct > 0)
|
|
losses += int(pnl_pct <= 0)
|
|
|
|
trades = wins + losses
|
|
win_rate = (wins / trades * 100.0) if trades else 0.0
|
|
avg_pnl_pct = (sum(pnl_values) / len(pnl_values)) if pnl_values else 0.0
|
|
return {
|
|
"trades": trades,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"win_rate": round(win_rate, 2),
|
|
"avg_pnl_pct": round(avg_pnl_pct, 2),
|
|
}
|
|
|
|
|
|
def simulate_trend_trades(df: pd.DataFrame, max_examples: int = 20) -> pd.DataFrame:
|
|
if len(df) < 4:
|
|
return pd.DataFrame(
|
|
columns=[
|
|
"direction",
|
|
"entry_timestamp",
|
|
"exit_timestamp",
|
|
"entry_price",
|
|
"exit_price",
|
|
"bars_held",
|
|
"pnl_pct",
|
|
"outcome",
|
|
]
|
|
)
|
|
|
|
trend_series = df["trend_state"]
|
|
trend_change = trend_series != trend_series.shift(1)
|
|
signal_idx = df.index[trend_change & trend_series.isin([TREND_BULL, TREND_BEAR])]
|
|
|
|
direction_by_trend = {
|
|
TREND_BULL: "LONG",
|
|
TREND_BEAR: "SHORT",
|
|
}
|
|
|
|
open_trade: dict[str, object] | None = None
|
|
closed_trades: list[dict[str, object]] = []
|
|
|
|
for idx in signal_idx:
|
|
pos = int(df.index.get_loc(idx))
|
|
trend_now = str(df.iloc[pos]["trend_state"])
|
|
direction = direction_by_trend[trend_now]
|
|
close_price = float(df.iloc[pos]["Close"])
|
|
|
|
if open_trade is None:
|
|
open_trade = {
|
|
"direction": direction,
|
|
"entry_timestamp": idx,
|
|
"entry_pos": pos,
|
|
"entry_price": close_price,
|
|
}
|
|
continue
|
|
|
|
if open_trade["direction"] == direction:
|
|
continue
|
|
|
|
entry_price = float(open_trade["entry_price"])
|
|
if str(open_trade["direction"]) == "LONG":
|
|
pnl_pct = ((close_price - entry_price) / entry_price) * 100.0
|
|
else:
|
|
pnl_pct = ((entry_price - close_price) / entry_price) * 100.0
|
|
|
|
closed_trades.append(
|
|
{
|
|
"direction": str(open_trade["direction"]),
|
|
"entry_timestamp": open_trade["entry_timestamp"],
|
|
"exit_timestamp": idx,
|
|
"entry_price": round(entry_price, 4),
|
|
"exit_price": round(close_price, 4),
|
|
"bars_held": pos - int(open_trade["entry_pos"]),
|
|
"pnl_pct": round(pnl_pct, 2),
|
|
"outcome": "Win" if pnl_pct > 0 else ("Loss" if pnl_pct < 0 else "Flat"),
|
|
}
|
|
)
|
|
|
|
# Flip into the new direction at the same confirmed reversal bar.
|
|
open_trade = {
|
|
"direction": direction,
|
|
"entry_timestamp": idx,
|
|
"entry_pos": pos,
|
|
"entry_price": close_price,
|
|
}
|
|
|
|
if not closed_trades:
|
|
return pd.DataFrame(
|
|
columns=[
|
|
"direction",
|
|
"entry_timestamp",
|
|
"exit_timestamp",
|
|
"entry_price",
|
|
"exit_price",
|
|
"bars_held",
|
|
"pnl_pct",
|
|
"outcome",
|
|
]
|
|
)
|
|
|
|
trades_df = pd.DataFrame(closed_trades)
|
|
if max_examples > 0 and len(trades_df) > max_examples:
|
|
trades_df = trades_df.iloc[-max_examples:].copy()
|
|
return trades_df
|