Files
2026-09-29 14:08:43 +07:00

526 lines
28 KiB
Python

"""Daily close-signal / next-open execution backtester; research only."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import math
import numpy as np
import pandas as pd
from .config import StrategyConfig, ThaiMarketRules, TradingCosts
from .portfolio import round_quantity, round_to_tick, size_position
@dataclass
class PendingEntry:
symbol: str
signal_date: pd.Timestamp
setup: str
entry_type: str
stop_reference: float
signal_close: float
ema21_high: float
atr14: float
risk_pct: float
regime: str
lot_size: int
@dataclass
class Position:
symbol: str
entry_date: pd.Timestamp
entry_price: float
initial_qty: int
remaining_qty: int
initial_stop: float
initial_r: float
target_2r: float
setup: str
entry_type: str
risk_pct: float
entry_cost: float
entry_price_at_fill: float
stop_at_fill: float
locked_initial_r: float
entry_shares: int
trim_pnl: float = 0.0
runner_pnl: float = 0.0
dividend_cash: float = 0.0
min_low: float = math.inf
max_high: float = -math.inf
holding_sessions: int = 0
trimmed: bool = False
structure_break_seen: bool = False
failed_reclaim_seen: bool = False
initial_risk: float = 0.0
@dataclass
class BacktestResult:
metrics: dict[str, float]
trades: pd.DataFrame
equity_curve: pd.DataFrame
fills: pd.DataFrame
skipped: list[dict[str, str]] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
return {"metrics": self.metrics, "trades": self.trades.to_dict("records"),
"equity_curve": self.equity_curve.reset_index().to_dict("records"),
"fills": self.fills.to_dict("records"), "skipped": self.skipped}
def _cost(notional: float, costs: TradingCosts, *, sell: bool) -> float:
return notional * (costs.commission_rate + costs.fees_rate + (costs.sell_tax_rate if sell else 0.0))
def _number(value: object, default: float = 0.0) -> float:
if value is None or pd.isna(value):
return default
return float(value)
def _fill_price(price: float, side: str, costs: TradingCosts, rules: ThaiMarketRules) -> float:
slip = costs.slippage_bps / 10_000
raw = price * (1 + slip if side == "buy" else 1 - slip)
return round_to_tick(raw, rules, direction="up" if side == "buy" else "down")
def _signal_for_day(features: pd.DataFrame, date: pd.Timestamp, strategy: StrategyConfig,
rules: ThaiMarketRules, sector_lot_size: int | None = None,
symbol: str = "") -> PendingEntry | None:
if date not in features.index:
return None
i = features.index.get_loc(date)
if isinstance(i, slice) or i < 1:
return None
row, prev = features.iloc[i], features.iloc[i - 1]
need = ["close", "open", "high", "low", "ema21_high", "ema21_low", "ema21_close", "atr14"]
if any(pd.isna(row.get(key, np.nan)) for key in need):
return None
regime = str(row.get("regime", "CORRECTION"))
if strategy.require_market_support and strategy.enable_market_regime:
if regime in {"CORRECTION", "BREAKDOWN", "OVERBOUGHT"} or not bool(row.get("new_risk_allowed", False)):
return None
rs = row.get("rs_percentile", 0.0)
if strategy.enable_rs and (pd.isna(rs) or float(rs) < strategy.min_rs_percentile):
return None
if strategy.enable_mco:
mco_z = row.get("mco_z", np.nan)
# MCO is a timing context; no standalone MCO buy rule. Extreme downside disables fresh risk.
if pd.notna(mco_z) and float(mco_z) < strategy.min_mco_z_for_entry:
return None
if strategy.enable_mcsi and str(row.get("mcsi_state", "DOWN")) in {"DOWN", "LOSE_10DMA"}:
if strategy.require_market_support:
return None
if strategy.enable_weekly and regime in {"REPAIR", "EARLY_UPTREND"}:
if row.get("weekly_trend_state", "NEUTRAL") == "DOWN":
return None
extension = _number(row.get("extension_atr", 0.0))
if strategy.enable_atr_filter and extension > strategy.max_extension_atr:
return None
if float(row["close"]) < strategy.min_price or _number(row.get("avg_value_turnover20", 0.0)) < strategy.min_value_turnover_thb:
return None
stop = float(row["ema21_low"])
if not (0 < stop < float(row["close"])):
return None
weakness = (row["trend_state"] == "UP" and row["low"] <= row["ema21_high"]
and row["close"] >= row["ema21_low"])
prior_high_reclaim = row["close"] > prev["high"]
red_to_green = row["open"] < prev["close"] and row["close"] > prev["close"]
reversal = row["close"] > row["open"] and prev["close"] < prev["open"]
ema_reclaim = row["close"] > row["ema21_high"] and prev["close"] <= prev["ema21_high"]
base_breakout = row["close"] > features["high"].iloc[max(0, i - strategy.base_breakout_window):i].max()
higher_low = bool(row.get("higher_low", False))
if strategy.enable_higher_low and not higher_low and not weakness:
# Confirmation setups remain available; only the specific higher-low pattern is disabled.
higher_low = False
if weakness:
setup, entry_type, risk_pct = "21DMA Retest", "WEAKNESS", strategy.weakness_risk_pct
elif prior_high_reclaim or ema_reclaim or red_to_green or reversal or base_breakout or (higher_low and row["close"] > prev["close"]):
setup = ("Base Breakout" if base_breakout else "Prior High Reclaim" if prior_high_reclaim
else "21DMA High Reclaim" if ema_reclaim else "Red-to-Green" if red_to_green
else "Daily Reversal" if reversal else "Higher Low")
entry_type, risk_pct = "CONFIRMATION", strategy.confirmation_risk_pct
else:
return None
if strategy.enable_higher_low and setup == "Higher Low" and not higher_low:
return None
return PendingEntry(
symbol=str(row.get("symbol", symbol)), signal_date=pd.Timestamp(date), setup=setup,
entry_type=entry_type, stop_reference=stop, signal_close=float(row["close"]),
ema21_high=float(row["ema21_high"]), atr14=float(row["atr14"]), risk_pct=risk_pct,
regime=regime, lot_size=sector_lot_size or rules.default_board_lot,
)
def _finish_trade(position: Position, date: pd.Timestamp) -> dict[str, object]:
total = position.trim_pnl + position.runner_pnl + position.dividend_cash
initial_risk = position.locked_initial_r * position.entry_shares
return {
"symbol": position.symbol, "entry_date": position.entry_date.date().isoformat(),
"exit_date": date.date().isoformat(), "setup": position.setup,
"entry_type": position.entry_type, "entry_price": position.entry_price_at_fill,
"initial_stop": position.stop_at_fill, "initial_R": position.locked_initial_r,
"initial_shares": position.entry_shares, "holding_sessions": position.holding_sessions,
"trim_2r_pnl": position.trim_pnl, "runner_pnl": position.runner_pnl,
"dividend_cash": position.dividend_cash, "total_pnl": total,
"r_multiple": total / initial_risk if initial_risk else 0.0,
"mae_r": (position.min_low - position.entry_price) / position.initial_r if position.initial_r else 0.0,
"mfe_r": (position.max_high - position.entry_price) / position.initial_r if position.initial_r else 0.0,
"two_r_hit": position.trimmed,
}
def calculate_metrics(equity_curve: pd.DataFrame, trades: pd.DataFrame) -> dict[str, float]:
if equity_curve.empty:
return {key: 0.0 for key in ["total_return", "cagr", "max_drawdown", "win_rate", "average_win",
"average_loss", "expectancy_per_trade", "expectancy_r", "profit_factor",
"average_r", "median_r", "mae_r", "mfe_r", "average_holding_period",
"exposure_pct", "trades_per_month", "two_r_hit_rate", "trim_2r_contribution",
"runner_contribution"]}
equity = equity_curve["equity"].astype(float)
total_return = equity.iloc[-1] / equity.iloc[0] - 1
years = max((equity.index[-1] - equity.index[0]).days / 365.25, 1 / 252)
cagr = (max(equity.iloc[-1], 1) / max(equity.iloc[0], 1)) ** (1 / years) - 1
dd = equity / equity.cummax() - 1
pnl = trades["total_pnl"].astype(float) if not trades.empty else pd.Series(dtype=float)
wins, losses = pnl[pnl > 0], pnl[pnl < 0]
gross_loss = abs(losses.sum())
average_r = float(trades["r_multiple"].mean()) if not trades.empty else 0.0
months = max(years * 12, 1 / 12)
exposure_pct = float(equity_curve["exposure"].mean()) if "exposure" in equity_curve else 0.0
return {
"total_return": float(total_return), "cagr": float(cagr), "max_drawdown": float(dd.min()),
"win_rate": float((pnl > 0).mean()) if len(pnl) else 0.0,
"average_win": float(wins.mean()) if len(wins) else 0.0,
"average_loss": float(losses.mean()) if len(losses) else 0.0,
"expectancy_per_trade": float(pnl.mean()) if len(pnl) else 0.0,
"expectancy_r": average_r,
"profit_factor": float(wins.sum() / gross_loss) if gross_loss else (float("inf") if len(wins) else 0.0),
"average_r": average_r,
"median_r": float(trades["r_multiple"].median()) if not trades.empty else 0.0,
"mae_r": float(trades["mae_r"].mean()) if not trades.empty else 0.0,
"mfe_r": float(trades["mfe_r"].mean()) if not trades.empty else 0.0,
"average_holding_period": float(trades["holding_sessions"].mean()) if not trades.empty else 0.0,
"exposure_pct": exposure_pct,
"trades_per_month": len(trades) / months,
"two_r_hit_rate": float(trades["two_r_hit"].mean()) if not trades.empty else 0.0,
"trim_2r_contribution": float(trades["trim_2r_pnl"].sum()) if not trades.empty else 0.0,
"runner_contribution": float(trades["runner_pnl"].sum()) if not trades.empty else 0.0,
}
def run_backtest(features_by_symbol: dict[str, pd.DataFrame], market: pd.DataFrame, *,
strategy: StrategyConfig | None = None, rules: ThaiMarketRules | None = None,
costs: TradingCosts | None = None, start: str | None = None, end: str | None = None,
lot_sizes: dict[str, int] | None = None, yahoo_split_adjusted: bool = True,
stalled_retest_sessions: int | None = None,
stalled_retest_progress_r: float = 1.0,
runner_protection_r: float | None = None) -> BacktestResult:
"""Backtest known close signals at next open; it never submits real orders."""
strategy, rules, costs = strategy or StrategyConfig(), rules or ThaiMarketRules(), costs or TradingCosts()
if stalled_retest_sessions is not None and stalled_retest_sessions < 1:
raise ValueError("stalled_retest_sessions must be positive")
if not math.isfinite(stalled_retest_progress_r) or stalled_retest_progress_r <= 0:
raise ValueError("stalled_retest_progress_r must be finite and positive")
if runner_protection_r is not None and (not math.isfinite(runner_protection_r) or runner_protection_r < 0):
raise ValueError("runner_protection_r must be finite and non-negative")
if not features_by_symbol:
raise ValueError("features_by_symbol is empty")
members_by_date = market.attrs.get("members_by_date")
external_dividends = {
str(symbol).upper(): {
pd.Timestamp(date).normalize(): float(amount) for date, amount in events.items()
}
for symbol, events in market.attrs.get("external_dividends", {}).items()
}
market_fields = [column for column in ("regime", "regime_reasons", "new_risk_allowed", "net_advances",
"mco_z", "mcsi_state", "set100ew_close", "set100ew_ema21",
"ew_trend_state") if column in market.columns]
if market_fields:
market_context = market[market_fields]
prepared: dict[str, pd.DataFrame] = {}
for symbol, source in features_by_symbol.items():
base = source.drop(columns=[column for column in market_fields if column in source.columns])
prepared[symbol] = base.join(market_context.reindex(base.index))
features_by_symbol = prepared
feature_dates = set().union(*(frame.index for frame in features_by_symbol.values()))
dates_set = set(feature_dates)
if feature_dates:
first_date, last_date = min(feature_dates), max(feature_dates)
dates_set.update(pd.Timestamp(date).normalize() for date in market.index
if first_date <= pd.Timestamp(date).normalize() <= last_date)
dates_set.update(date for events in external_dividends.values() for date in events
if first_date <= date <= last_date)
dates = sorted(dates_set)
if start:
dates = [date for date in dates if date >= pd.Timestamp(start)]
if end:
dates = [date for date in dates if date < pd.Timestamp(end)]
if len(dates) < 2:
raise ValueError("backtest requires at least two daily bars")
cash, positions = float(strategy.initial_equity), {}
pending_entries: list[PendingEntry] = []
pending_exits: dict[str, str] = {}
closed_trades: list[dict[str, object]] = []
fills: list[dict[str, object]] = []
skipped: list[dict[str, str]] = []
equity_rows = []
previous_close: dict[str, float] = {}
def execute_sell(position: Position, qty: int, reference_price: float, date: pd.Timestamp, reason: str,
*, runner: bool) -> int:
nonlocal cash
qty = min(qty, position.remaining_qty)
if qty <= 0:
return 0
fill = _fill_price(reference_price, "sell", costs, rules)
# Include the actual execution point in trade excursions. A next-open exit
# can gap beyond every low seen before the position was removed from the book.
position.min_low = min(position.min_low, fill)
position.max_high = max(position.max_high, fill)
notional = fill * qty
sale_cost = _cost(notional, costs, sell=True)
entry_basis = position.entry_cost * qty / position.initial_qty
pnl = notional - sale_cost - entry_basis
cash += notional - sale_cost
if runner:
position.runner_pnl += pnl
else:
position.trim_pnl += pnl
position.remaining_qty -= qty
fills.append({"date": date, "symbol": position.symbol, "side": "SELL", "reason": reason,
"shares": qty, "price": fill, "cost": sale_cost})
return qty
for date in dates:
# Yahoo OHLC is split-adjusted; retain the event for audit but avoid double-adjusting shares.
# Alternative providers may supply unadjusted OHLC and opt in to explicit split handling.
for symbol, position in list(positions.items()):
frame = features_by_symbol[symbol]
bar = frame.loc[date] if date in frame.index else None
if bar is not None:
split = _number(bar.get("stock_splits", 0.0))
if not yahoo_split_adjusted and split > 0 and split != 1:
position.remaining_qty = int(round(position.remaining_qty * split))
position.initial_qty = int(round(position.initial_qty * split))
position.entry_price /= split
position.initial_stop /= split
position.initial_r /= split
position.target_2r = position.entry_price + 2 * position.initial_r
if math.isfinite(position.min_low):
position.min_low /= split
if math.isfinite(position.max_high):
position.max_high /= split
event_amount = external_dividends.get(symbol, {}).get(pd.Timestamp(date).normalize())
dividend = (float(event_amount) if event_amount is not None else
_number(bar.get("dividends", 0.0)) if bar is not None else 0.0)
if dividend and date > position.entry_date:
dividend_amount = position.remaining_qty * dividend
position.dividend_cash += dividend_amount
cash += dividend_amount
# If an ex-date has no stock bar, reduce the carried mark by the
# distribution so cash receipt alone does not manufacture return.
if bar is None and symbol in previous_close:
previous_close[symbol] = max(0.0, previous_close[symbol] - dividend)
for symbol, reason in list(pending_exits.items()):
if symbol not in positions:
pending_exits.pop(symbol, None)
continue
frame = features_by_symbol[symbol]
if date not in frame.index or pd.isna(frame.loc[date].get("open", np.nan)):
continue
position = positions[symbol]
execute_sell(position, position.remaining_qty, float(frame.loc[date, "open"]), date, reason, runner=True)
if position.remaining_qty == 0:
closed_trades.append(_finish_trade(position, date))
positions.pop(symbol)
pending_exits.pop(symbol, None)
for entry in pending_entries:
frame = features_by_symbol[entry.symbol]
if date not in frame.index or entry.symbol in positions:
continue
bar = frame.loc[date]
if pd.isna(bar.get("open", np.nan)):
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "missing next open"})
continue
open_price = float(bar["open"])
if open_price > entry.ema21_high + strategy.max_extension_atr * entry.atr14:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "next-open gap exceeds ATR extension limit"})
continue
stop = entry.stop_reference
if open_price <= stop:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "open at/below structural stop"})
continue
fill_price = _fill_price(open_price, "buy", costs, rules)
live_market_value = 0.0
for held_symbol, held_position in positions.items():
held_frame = features_by_symbol[held_symbol]
if date in held_frame.index and pd.notna(held_frame.loc[date].get("open", np.nan)):
mark = float(held_frame.loc[date, "open"])
else:
mark = previous_close.get(held_symbol, held_position.entry_price)
live_market_value += mark * held_position.remaining_qty
open_equity = cash + live_market_value
current_heat = sum(p.initial_r * p.remaining_qty for p in positions.values()) / max(open_equity, 1.0)
heat_limit = strategy.heat.for_regime(entry.regime)
if heat_limit <= 0 or current_heat >= heat_limit:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": f"portfolio heat limit for {entry.regime}"})
continue
sizing = size_position(open_equity, entry.risk_pct, fill_price, stop,
lot_size=entry.lot_size, max_position_pct=strategy.max_position_pct, rules=rules)
quantity = int(sizing["shares"])
if quantity <= 0:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "risk/position cap below one board lot"})
continue
projected_heat = (sum(p.initial_r * p.remaining_qty for p in positions.values())
+ (fill_price - stop) * quantity) / max(open_equity, 1.0)
if projected_heat > heat_limit + 1e-12:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "new position exceeds portfolio heat"})
continue
buy_notional = fill_price * quantity
buy_cost = _cost(buy_notional, costs, sell=False)
if buy_notional + buy_cost > cash:
quantity = round_quantity(cash / (fill_price * (1 + costs.commission_rate + costs.fees_rate)), entry.lot_size)
buy_notional = fill_price * quantity
buy_cost = _cost(buy_notional, costs, sell=False)
if quantity <= 0:
skipped.append({"symbol": entry.symbol, "date": date.date().isoformat(), "reason": "insufficient cash"})
continue
initial_r = fill_price - stop
position = Position(entry.symbol, date, fill_price, quantity, quantity, stop, initial_r,
fill_price + strategy.partial_r_multiple * initial_r, entry.setup,
entry.entry_type, entry.risk_pct, buy_notional + buy_cost,
entry_price_at_fill=fill_price, stop_at_fill=stop,
locked_initial_r=initial_r, entry_shares=quantity,
initial_risk=initial_r * quantity)
positions[entry.symbol] = position
cash -= buy_notional + buy_cost
fills.append({"date": date, "symbol": entry.symbol, "side": "BUY", "reason": entry.setup,
"shares": quantity, "price": fill_price, "cost": buy_cost})
pending_entries.clear()
# Daily high/low checks happen after next-open orders. Ambiguous stop/target bars assume stop first.
for symbol, position in list(positions.items()):
frame = features_by_symbol[symbol]
if date not in frame.index:
continue
bar = frame.loc[date]
op, hi, lo, cl = (float(bar[k]) for k in ("open", "high", "low", "close"))
runner_was_trimmed = position.trimmed
position.holding_sessions += 1
position.min_low = min(position.min_low, lo)
position.max_high = max(position.max_high, hi)
if runner_was_trimmed and runner_protection_r is not None:
runner_floor = position.entry_price + runner_protection_r * position.initial_r
if lo <= runner_floor:
fill_ref = min(op, runner_floor) if op < runner_floor else runner_floor
execute_sell(position, position.remaining_qty, fill_ref, date,
"runner_protection_after_2r", runner=True)
if position.remaining_qty == 0:
closed_trades.append(_finish_trade(position, date))
positions.pop(symbol)
continue
emergency = position.entry_price * (1 - strategy.max_loss_pct)
if lo <= emergency:
fill_ref = min(op, emergency) if op < emergency else emergency
execute_sell(position, position.remaining_qty, fill_ref, date, "emergency_max_loss", runner=True)
if position.remaining_qty == 0:
closed_trades.append(_finish_trade(position, date))
positions.pop(symbol)
continue
if not position.trimmed and hi >= position.target_2r:
if lo <= position.initial_stop:
execute_sell(position, position.remaining_qty, lo, date, "structural_stop_ambiguous_bar", runner=True)
if position.remaining_qty == 0:
closed_trades.append(_finish_trade(position, date))
positions.pop(symbol)
continue
requested = int(position.initial_qty * strategy.partial_fraction)
trim_qty = max(rules.minimum_quantity, requested)
trim_qty = min(trim_qty, position.remaining_qty)
execute_sell(position, trim_qty, max(op, position.target_2r), date, "2R_trim", runner=False)
position.trimmed = True
ema_low = bar.get("ema21_low", np.nan)
below = pd.notna(ema_low) and cl < float(ema_low)
if strategy.exit_mode == "close_below_ema21_low" and below:
pending_exits[symbol] = "close_below_ema21_low_next_open"
elif strategy.exit_mode == "break_failed_reclaim" and pd.notna(ema_low):
if cl > float(bar.get("ema21_high", math.inf)):
position.structure_break_seen = False
position.failed_reclaim_seen = False
elif below:
if position.failed_reclaim_seen:
pending_exits[symbol] = "break_failed_reclaim_next_open"
else:
position.structure_break_seen = True
elif position.structure_break_seen:
# A close back into/above the structure is the reclaim attempt.
position.failed_reclaim_seen = True
if (stalled_retest_sessions is not None
and position.entry_type == "WEAKNESS"
and not position.trimmed
and position.holding_sessions >= stalled_retest_sessions
and position.max_high < position.entry_price + stalled_retest_progress_r * position.initial_r):
pending_exits.setdefault(
symbol, f"stalled_retest_without_{stalled_retest_progress_r:g}r_"
f"after_{stalled_retest_sessions}_sessions_next_open"
)
if hi < math.inf:
previous_close[symbol] = cl
# Signal generation uses only this day's close and features; every fill is scheduled for the next open.
for symbol, frame in features_by_symbol.items():
if symbol in positions or any(item.symbol == symbol for item in pending_entries):
continue
if date not in frame.index:
continue
if members_by_date is not None and symbol not in members_by_date.get(pd.Timestamp(date), []):
continue
signal = _signal_for_day(frame, date, strategy, rules, (lot_sizes or {}).get(symbol), symbol=symbol)
if signal is not None:
pending_entries.append(signal)
close_market_value = 0.0
heat_risk = 0.0
unrealized = 0.0
for symbol, position in positions.items():
if date in features_by_symbol[symbol].index:
close = float(features_by_symbol[symbol].loc[date, "close"])
previous_close[symbol] = close
else:
close = previous_close.get(symbol, position.entry_price)
close_market_value += close * position.remaining_qty
unrealized += (close - position.entry_price) * position.remaining_qty
heat_risk += position.initial_r * position.remaining_qty
equity = cash + close_market_value
equity_rows.append({"date": date, "equity": equity, "cash": cash,
"market_value": close_market_value, "portfolio_heat": heat_risk / equity if equity else 0,
"portfolio_cushion": unrealized / equity if equity else 0,
"exposure": close_market_value / equity if equity else 0.0,
"open_positions": len(positions)})
# Liquidate at last available close to report realized runner attribution.
final_date = dates[-1]
for symbol, position in list(positions.items()):
frame = features_by_symbol[symbol]
available = frame.loc[frame.index <= final_date]
if available.empty:
continue
bar = available.iloc[-1]
execute_sell(position, position.remaining_qty, float(bar["close"]), final_date, "end_of_test", runner=True)
closed_trades.append(_finish_trade(position, final_date))
positions.pop(symbol)
# Reflect final liquidation costs in the terminal equity observation.
if equity_rows:
equity_rows[-1].update({"equity": cash, "cash": cash, "market_value": 0.0,
"portfolio_heat": 0.0, "portfolio_cushion": 0.0,
"exposure": 0.0, "open_positions": 0})
trade_frame = pd.DataFrame(closed_trades)
curve = pd.DataFrame(equity_rows).set_index("date")
fill_frame = pd.DataFrame(fills)
return BacktestResult(calculate_metrics(curve, trade_frame), trade_frame, curve, fill_frame, skipped)