526 lines
28 KiB
Python
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)
|