Przeglądaj źródła

feat: add ultrashort strategy exploration

lxy 1 miesiąc temu
rodzic
commit
91b8a0b142

+ 3548 - 0
scripts/explore_ultrashort.py

@@ -0,0 +1,3548 @@
+from __future__ import annotations
+
+import argparse
+import json
+from dataclasses import dataclass
+from math import sqrt
+from pathlib import Path
+
+import pandas as pd
+
+from okx_codex_trader.bbmr_report import BBMRConfig, run_bbmr_segment
+from okx_codex_trader.bbsb_report import BBSBConfig, run_bbsb_segment
+from okx_codex_trader.donchian_report import DonchianConfig, run_donchian_segment
+from okx_codex_trader.ema_pullback_report import EMAPullbackConfig, run_ema_pullback_segment
+from okx_codex_trader.models import Candle
+from okx_codex_trader.okx_client import OkxClient
+from okx_codex_trader.rsi2_report import RSI2Config, _compute_rsi, run_rsi2_segment
+from okx_codex_trader.sampled_report import SegmentResult, mark_to_market, sample_segments, trade_equity
+
+
+SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
+BARS = ("1m", "3m", "5m")
+ANALYSIS_BARS = ("3m", "5m", "15m")
+HISTORY_LIMIT = 4000
+SEGMENTS = 8
+WINDOW_SIZE = 240
+LEVERAGE = 3
+INITIAL_EQUITY = 10_000.0
+ROBUST_HISTORY_LIMIT = 50_000
+GROSS_RETURN_NOTE = "Gross-return backtest only: fees, slippage, and funding rates are excluded."
+MINUTES_PER_YEAR = 365 * 24 * 60
+CANDLE_CACHE_DIR = Path("data/okx-candles")
+
+
+@dataclass(frozen=True)
+class Candidate:
+    name: str
+    warmup_bars: int
+    run: object
+
+
+@dataclass(frozen=True)
+class PairCandidate:
+    name: str
+    warmup_bars: int
+    run: object
+
+
+def _format_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def candle_cache_file(cache_dir: Path, symbol: str, bar: str) -> Path:
+    return cache_dir / symbol / f"{bar}.csv"
+
+
+def candle_cache_meta_file(cache_dir: Path, symbol: str, bar: str) -> Path:
+    return cache_dir / symbol / f"{bar}.meta.json"
+
+
+def load_cached_candles(cache_dir: Path, symbol: str, bar: str) -> tuple[list[Candle], bool]:
+    cache_file = candle_cache_file(cache_dir, symbol, bar)
+    if not cache_file.exists():
+        return [], False
+    frame = pd.read_csv(cache_file)
+    candles = [
+        Candle(
+            symbol=symbol,
+            ts=int(row.ts),
+            open=float(row.open),
+            high=float(row.high),
+            low=float(row.low),
+            close=float(row.close),
+            volume=float(row.volume),
+        )
+        for row in frame.itertuples(index=False)
+    ]
+    meta_file = candle_cache_meta_file(cache_dir, symbol, bar)
+    history_exhausted = False
+    if meta_file.exists():
+        with meta_file.open("r", encoding="utf-8") as handle:
+            history_exhausted = bool(json.load(handle).get("history_exhausted"))
+    return candles, history_exhausted
+
+
+def save_cached_candles(cache_dir: Path, symbol: str, bar: str, candles: list[Candle], history_exhausted: bool) -> None:
+    cache_file = candle_cache_file(cache_dir, symbol, bar)
+    cache_file.parent.mkdir(parents=True, exist_ok=True)
+    frame = pd.DataFrame(
+        [
+            {
+                "ts": candle.ts,
+                "open": candle.open,
+                "high": candle.high,
+                "low": candle.low,
+                "close": candle.close,
+                "volume": candle.volume,
+            }
+            for candle in sorted(candles, key=lambda candle: candle.ts)
+        ]
+    ).drop_duplicates("ts", keep="last")
+    frame.to_csv(cache_file, index=False)
+    meta = {
+        "symbol": symbol,
+        "bar": bar,
+        "history_exhausted": history_exhausted,
+        "rows": len(frame),
+        "first_ts": int(frame["ts"].iloc[0]) if len(frame) else None,
+        "last_ts": int(frame["ts"].iloc[-1]) if len(frame) else None,
+    }
+    with candle_cache_meta_file(cache_dir, symbol, bar).open("w", encoding="utf-8") as handle:
+        json.dump(meta, handle, separators=(",", ":"))
+
+
+def get_candles_cached(
+    client: OkxClient,
+    symbol: str,
+    bar: str,
+    limit: int,
+    cache_dir: Path = CANDLE_CACHE_DIR,
+) -> list[Candle]:
+    cached, history_exhausted = load_cached_candles(cache_dir, symbol, bar)
+    if cached and (len(cached) >= limit or history_exhausted):
+        latest = client.get_candles(symbol, bar, min(300, limit))
+        merged = {candle.ts: candle for candle in cached}
+        for candle in latest:
+            merged[candle.ts] = candle
+        candles = sorted(merged.values(), key=lambda candle: candle.ts)
+        save_cached_candles(cache_dir, symbol, bar, candles, history_exhausted)
+        return candles[-limit:] if len(candles) >= limit else candles
+
+    fetched = client.get_candles(symbol, bar, limit)
+    history_exhausted = len(fetched) < limit
+    save_cached_candles(cache_dir, symbol, bar, fetched, history_exhausted)
+    return fetched
+
+
+def align_pair_candles(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
+    right_by_ts = {candle.ts: candle for candle in right}
+    left_aligned: list[Candle] = []
+    right_aligned: list[Candle] = []
+    for candle in left:
+        other = right_by_ts.get(candle.ts)
+        if other is None:
+            continue
+        left_aligned.append(candle)
+        right_aligned.append(other)
+    return left_aligned, right_aligned
+
+
+def _trade(
+    *,
+    trades: list[dict[str, object]],
+    exits: list[dict[str, object]],
+    position: dict[str, object],
+    candle: Candle,
+    exit_price: float,
+    leverage: int,
+) -> tuple[float, bool]:
+    exit_equity = trade_equity(
+        side=str(position["side"]),
+        margin_used=float(position["margin_used"]),
+        entry_price=float(position["entry_price"]),
+        exit_price=exit_price,
+        leverage=leverage,
+    )
+    trades.append(
+        {
+            "side": "Long" if position["side"] == "long" else "Short",
+            "entry_time": _format_ts(int(position["entry_time"])),
+            "exit_time": _format_ts(candle.ts),
+            "entry_price": round(float(position["entry_price"]), 4),
+            "exit_price": round(exit_price, 4),
+            "pnl": round(exit_equity - float(position["margin_used"]), 4),
+            "return_pct": round((exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100, 4),
+        }
+    )
+    exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
+    return exit_equity, exit_equity > float(position["margin_used"])
+
+
+def run_range_momentum_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    lookback: int,
+    take_profit_pct: float,
+    stop_loss_pct: float,
+) -> SegmentResult:
+    highs = pd.Series([candle.high for candle in candles], dtype=float)
+    lows = pd.Series([candle.low for candle in candles], dtype=float)
+    entry_high = highs.shift(1).rolling(lookback).max().tolist()
+    entry_low = lows.shift(1).rolling(lookback).min().tolist()
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_entry_side is not None and position is None and equity > 0.0:
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct if pending_entry_side == "long" else 1 + stop_loss_pct),
+                "take_profit_price": candle.open * (1 + take_profit_pct if pending_entry_side == "long" else 1 - take_profit_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]):
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            take_hit = (
+                position["side"] == "long" and candle.high >= float(position["take_profit_price"])
+            ) or (
+                position["side"] == "short" and candle.low <= float(position["take_profit_price"])
+            )
+            if stop_hit or take_hit:
+                exit_price = float(position["stop_price"] if stop_hit else position["take_profit_price"])
+                equity, won = _trade(
+                    trades=trades,
+                    exits=exits,
+                    position=position,
+                    candle=candle,
+                    exit_price=exit_price,
+                    leverage=leverage,
+                )
+                wins += 1 if won else 0
+                current_equity = equity
+                position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or position is not None or equity <= 0.0:
+            continue
+        if entry_high[index] == entry_high[index] and candle.close > float(entry_high[index]):
+            pending_entry_side = "long"
+        elif entry_low[index] == entry_low[index] and candle.close < float(entry_low[index]):
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_vwap_reversion_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    window: int,
+    entry_z: float,
+    exit_z: float,
+    stop_loss_pct: float,
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    volumes = pd.Series([candle.volume for candle in candles], dtype=float)
+    vwap = (closes * volumes).rolling(window).sum() / volumes.rolling(window).sum()
+    deviation = ((closes - vwap) / vwap).tolist()
+    stdev = pd.Series(deviation, dtype=float).rolling(window).std(ddof=0).tolist()
+    zscore = [
+        float("nan") if dev != dev or std != std or std == 0.0 else dev / std
+        for dev, std in zip(deviation, stdev)
+    ]
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry_side is not None and position is None and equity > 0.0:
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct if pending_entry_side == "long" else 1 + stop_loss_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None:
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                equity, won = _trade(
+                    trades=trades,
+                    exits=exits,
+                    position=position,
+                    candle=candle,
+                    exit_price=float(position["stop_price"]),
+                    leverage=leverage,
+                )
+                wins += 1 if won else 0
+                current_equity = equity
+                position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+        current_z = zscore[index]
+        if current_z != current_z:
+            continue
+        if position is not None:
+            if (position["side"] == "long" and current_z >= -exit_z) or (
+                position["side"] == "short" and current_z <= exit_z
+            ):
+                pending_exit = True
+            continue
+        if current_z <= -entry_z:
+            pending_entry_side = "long"
+        elif current_z >= entry_z:
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_rsi2_side_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    config: RSI2Config,
+    side_mode: str,
+) -> SegmentResult:
+    result = run_rsi2_segment(
+        candles=candles,
+        leverage=leverage,
+        warmup_bars=warmup_bars,
+        config=config,
+    )
+    if side_mode == "both":
+        return result
+
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    trend = closes.rolling(config.trend_sma).mean().tolist()
+    rsi_values = _compute_rsi(closes, config.rsi_length)
+
+    equity = config.initial_equity
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+    allowed_side = "long" if side_mode == "long" else "short"
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry_side is not None and position is None and equity > 0.0:
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None:
+            current_equity = mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_rsi = rsi_values[index]
+        current_trend = trend[index]
+        if current_rsi != current_rsi or current_trend != current_trend:
+            continue
+        if position is not None:
+            if (position["side"] == "long" and current_rsi >= config.exit_rsi) or (
+                position["side"] == "short" and current_rsi <= config.exit_rsi
+            ):
+                pending_exit = True
+            continue
+        if allowed_side == "long" and candle.close > float(current_trend) and current_rsi <= config.rsi_long_threshold:
+            pending_entry_side = "long"
+        elif allowed_side == "short" and candle.close < float(current_trend) and current_rsi >= config.rsi_short_threshold:
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - config.initial_equity) / config.initial_equity,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_rsi2_long_guarded_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    trend_sma: int,
+    rsi_threshold: float,
+    exit_rsi: float,
+    stop_loss_pct: float,
+    max_hold_bars: int,
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    trend = closes.rolling(trend_sma).mean().tolist()
+    rsi_values = _compute_rsi(closes, 2)
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]) and candle.low <= float(position["stop_price"]):
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=float(position["stop_price"]),
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            current_equity = equity
+            position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_rsi = rsi_values[index]
+        current_trend = trend[index]
+        if current_rsi != current_rsi or current_trend != current_trend:
+            continue
+        if position is not None:
+            held_bars = index - int(position["entry_index"])
+            if current_rsi >= exit_rsi or held_bars >= max_hold_bars:
+                pending_exit = True
+            continue
+        if candle.close > float(current_trend) and current_rsi <= rsi_threshold:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=(wins / trade_count) if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_ma_cross_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    fast: int,
+    slow: int,
+    side_mode: str,
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    fast_ma = closes.rolling(fast).mean().tolist()
+    slow_ma = closes.rolling(slow).mean().tolist()
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry_side: str | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry_side is not None and position is None and equity > 0.0:
+            position = {
+                "side": pending_entry_side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": pending_entry_side})
+            pending_entry_side = None
+
+        current_equity = equity
+        if position is not None:
+            current_equity = mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_fast = fast_ma[index]
+        current_slow = slow_ma[index]
+        previous_fast = fast_ma[index - 1]
+        previous_slow = slow_ma[index - 1]
+        if current_fast != current_fast or current_slow != current_slow or previous_fast != previous_fast or previous_slow != previous_slow:
+            continue
+        crossed_up = previous_fast <= previous_slow and current_fast > current_slow
+        crossed_down = previous_fast >= previous_slow and current_fast < current_slow
+        if position is not None:
+            if (position["side"] == "long" and crossed_down) or (position["side"] == "short" and crossed_up):
+                pending_exit = True
+            continue
+        if crossed_up and side_mode in {"both", "long"}:
+            pending_entry_side = "long"
+        elif crossed_down and side_mode in {"both", "short"}:
+            pending_entry_side = "short"
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_trend_rsi_bb_long_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    trend_sma: int,
+    band_length: int,
+    std_multiplier: float,
+    rsi_threshold: float,
+    exit_rsi: float,
+    stop_loss_pct: float,
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    trend = closes.rolling(trend_sma).mean().tolist()
+    middle = closes.rolling(band_length).mean().tolist()
+    stdev = closes.rolling(band_length).std(ddof=0).tolist()
+    lower = [
+        float("nan") if middle_value != middle_value or std_value != std_value else middle_value - std_multiplier * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    rsi_values = _compute_rsi(closes, 2)
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]) and candle.low <= float(position["stop_price"]):
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=float(position["stop_price"]),
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            current_equity = equity
+            position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_rsi = rsi_values[index]
+        current_trend = trend[index]
+        current_middle = middle[index]
+        current_lower = lower[index]
+        if current_rsi != current_rsi or current_trend != current_trend or current_middle != current_middle or current_lower != current_lower:
+            continue
+        if position is not None:
+            if current_rsi >= exit_rsi or candle.close >= float(current_middle):
+                pending_exit = True
+            continue
+        if candle.close > float(current_trend) and candle.close <= float(current_lower) and current_rsi <= rsi_threshold:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_regime_hybrid_segment(
+    *,
+    candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    trend_sma: int,
+    regime_lookback: int,
+    neutral_ma_distance: float,
+    rsi_long_threshold: float,
+    rsi_exit: float,
+    bb_length: int,
+    bb_std: float,
+    bb_bandwidth_lookback: int,
+    stop_loss_pct: float,
+) -> SegmentResult:
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    trend = closes.rolling(trend_sma).mean().tolist()
+    rsi_values = _compute_rsi(closes, 2)
+    middle = closes.rolling(bb_length).mean().tolist()
+    stdev = closes.rolling(bb_length).std(ddof=0).tolist()
+    upper = [
+        float("nan") if middle_value != middle_value or std_value != std_value else middle_value + bb_std * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    lower = [
+        float("nan") if middle_value != middle_value or std_value != std_value else middle_value - bb_std * std_value
+        for middle_value, std_value in zip(middle, stdev)
+    ]
+    bandwidth = [
+        float("nan") if upper_value != upper_value or lower_value != lower_value or middle_value != middle_value or middle_value == 0.0 else (upper_value - lower_value) / middle_value
+        for upper_value, lower_value, middle_value in zip(upper, lower, middle)
+    ]
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry: dict[str, object] | None = None
+    pending_exit = False
+
+    for index in range(warmup_bars, len(candles)):
+        candle = candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry is not None and position is None and equity > 0.0:
+            side = str(pending_entry["side"])
+            position = {
+                "side": side,
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct if side == "long" else 1 + stop_loss_pct),
+                "mode": str(pending_entry["mode"]),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": side})
+            pending_entry = None
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]):
+            stop_hit = (
+                position["side"] == "long" and candle.low <= float(position["stop_price"])
+            ) or (
+                position["side"] == "short" and candle.high >= float(position["stop_price"])
+            )
+            if stop_hit:
+                equity, won = _trade(
+                    trades=trades,
+                    exits=exits,
+                    position=position,
+                    candle=candle,
+                    exit_price=float(position["stop_price"]),
+                    leverage=leverage,
+                )
+                wins += 1 if won else 0
+                current_equity = equity
+                position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side=str(position["side"]),
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(candles) - 1 or equity <= 0.0:
+            continue
+
+        current_trend = trend[index]
+        current_rsi = rsi_values[index]
+        current_middle = middle[index]
+        current_upper = upper[index]
+        current_lower = lower[index]
+        if (
+            current_trend != current_trend
+            or current_rsi != current_rsi
+            or current_middle != current_middle
+            or current_upper != current_upper
+            or current_lower != current_lower
+        ):
+            continue
+
+        if position is not None:
+            if position["mode"] == "rsi" and (current_rsi >= rsi_exit or candle.close < float(current_trend)):
+                pending_exit = True
+            elif position["mode"] == "bbmr" and (
+                (position["side"] == "long" and candle.close >= float(current_middle))
+                or (position["side"] == "short" and candle.close <= float(current_middle))
+            ):
+                pending_exit = True
+            continue
+
+        regime_return = candle.close / candles[index - regime_lookback].close - 1.0
+        ma_distance = candle.close / float(current_trend) - 1.0
+        if candle.close > float(current_trend) and regime_return > 0.0 and current_rsi <= rsi_long_threshold:
+            pending_entry = {"side": "long", "mode": "rsi"}
+            continue
+
+        previous_bandwidths = [value for value in bandwidth[max(0, index - bb_bandwidth_lookback) : index] if value == value]
+        if abs(ma_distance) > neutral_ma_distance or len(previous_bandwidths) < bb_bandwidth_lookback:
+            continue
+        if bandwidth[index] == bandwidth[index] and bandwidth[index] <= pd.Series(previous_bandwidths, dtype=float).median():
+            if candle.close < float(current_lower):
+                pending_entry = {"side": "long", "mode": "bbmr"}
+            elif candle.close > float(current_upper):
+                pending_entry = {"side": "short", "mode": "bbmr"}
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_eth_btc_rsi_filter_segment(
+    *,
+    eth_candles: list[Candle],
+    btc_candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    eth_trend_sma: int,
+    eth_rsi_threshold: float,
+    eth_exit_rsi: float,
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+) -> SegmentResult:
+    eth_closes = pd.Series([candle.close for candle in eth_candles], dtype=float)
+    btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
+    eth_trend = eth_closes.rolling(eth_trend_sma).mean().tolist()
+    eth_rsi = _compute_rsi(eth_closes, 2)
+    btc_trend = btc_closes.rolling(btc_trend_sma).mean().tolist()
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(eth_candles)):
+        candle = eth_candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(eth_candles) - 1 or equity <= 0.0:
+            continue
+
+        current_eth_trend = eth_trend[index]
+        current_eth_rsi = eth_rsi[index]
+        current_btc_trend = btc_trend[index]
+        if current_eth_trend != current_eth_trend or current_eth_rsi != current_eth_rsi or current_btc_trend != current_btc_trend:
+            continue
+        if position is not None:
+            if current_eth_rsi >= eth_exit_rsi or btc_candles[index].close < float(current_btc_trend):
+                pending_exit = True
+            continue
+
+        btc_momentum = btc_candles[index].close / btc_candles[index - btc_momentum_lookback].close - 1.0
+        btc_risk_on = btc_candles[index].close > float(current_btc_trend) and btc_momentum >= btc_min_momentum
+        eth_pullback = candle.close > float(current_eth_trend) and current_eth_rsi <= eth_rsi_threshold
+        if btc_risk_on and eth_pullback:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=eth_candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_eth_btc_shock_filter_segment(
+    *,
+    eth_candles: list[Candle],
+    btc_candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    eth_trend_sma: int,
+    eth_rsi_threshold: float,
+    eth_exit_rsi: float,
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+    btc_shock_lookback: int,
+    btc_max_realized_vol: float,
+    btc_max_drawdown: float,
+) -> SegmentResult:
+    eth_closes = pd.Series([candle.close for candle in eth_candles], dtype=float)
+    btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
+    eth_trend = eth_closes.rolling(eth_trend_sma).mean().tolist()
+    eth_rsi = _compute_rsi(eth_closes, 2)
+    btc_trend = btc_closes.rolling(btc_trend_sma).mean().tolist()
+    btc_realized_vol = btc_closes.pct_change().rolling(btc_shock_lookback).std(ddof=1).tolist()
+    btc_recent_high = btc_closes.rolling(btc_shock_lookback).max().tolist()
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(eth_candles)):
+        candle = eth_candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "margin_used": equity,
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(eth_candles) - 1 or equity <= 0.0:
+            continue
+
+        current_eth_trend = eth_trend[index]
+        current_eth_rsi = eth_rsi[index]
+        current_btc_trend = btc_trend[index]
+        current_btc_vol = btc_realized_vol[index]
+        current_btc_high = btc_recent_high[index]
+        if (
+            current_eth_trend != current_eth_trend
+            or current_eth_rsi != current_eth_rsi
+            or current_btc_trend != current_btc_trend
+            or current_btc_vol != current_btc_vol
+            or current_btc_high != current_btc_high
+        ):
+            continue
+        btc_drawdown = btc_candles[index].close / float(current_btc_high) - 1.0
+        btc_shock_ok = current_btc_vol <= btc_max_realized_vol and btc_drawdown >= -btc_max_drawdown
+        if position is not None:
+            if current_eth_rsi >= eth_exit_rsi or btc_candles[index].close < float(current_btc_trend) or not btc_shock_ok:
+                pending_exit = True
+            continue
+
+        btc_momentum = btc_candles[index].close / btc_candles[index - btc_momentum_lookback].close - 1.0
+        btc_risk_on = (
+            btc_candles[index].close > float(current_btc_trend)
+            and btc_momentum >= btc_min_momentum
+            and btc_shock_ok
+        )
+        eth_pullback = candle.close > float(current_eth_trend) and current_eth_rsi <= eth_rsi_threshold
+        if btc_risk_on and eth_pullback:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=eth_candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_eth_btc_ratio_pullback_segment(
+    *,
+    eth_candles: list[Candle],
+    btc_candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+    ratio_length: int,
+    ratio_std: float,
+    ratio_rsi_threshold: float,
+    stop_loss_pct: float,
+) -> SegmentResult:
+    eth_closes = pd.Series([candle.close for candle in eth_candles], dtype=float)
+    btc_closes = pd.Series([candle.close for candle in btc_candles], dtype=float)
+    ratio = eth_closes / btc_closes
+    btc_trend = btc_closes.rolling(btc_trend_sma).mean().tolist()
+    ratio_middle = ratio.rolling(ratio_length).mean().tolist()
+    ratio_stdev = ratio.rolling(ratio_length).std(ddof=0).tolist()
+    ratio_lower = [
+        float("nan") if middle != middle or stdev != stdev else middle - ratio_std * stdev
+        for middle, stdev in zip(ratio_middle, ratio_stdev)
+    ]
+    ratio_rsi = _compute_rsi(ratio, 2)
+
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(eth_candles)):
+        candle = eth_candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1 - stop_loss_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]) and candle.low <= float(position["stop_price"]):
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=float(position["stop_price"]),
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            current_equity = equity
+            position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(eth_candles) - 1 or equity <= 0.0:
+            continue
+
+        current_btc_trend = btc_trend[index]
+        current_ratio_middle = ratio_middle[index]
+        current_ratio_lower = ratio_lower[index]
+        current_ratio_rsi = ratio_rsi[index]
+        if (
+            current_btc_trend != current_btc_trend
+            or current_ratio_middle != current_ratio_middle
+            or current_ratio_lower != current_ratio_lower
+            or current_ratio_rsi != current_ratio_rsi
+        ):
+            continue
+
+        btc_momentum = btc_candles[index].close / btc_candles[index - btc_momentum_lookback].close - 1.0
+        btc_risk_on = btc_candles[index].close > float(current_btc_trend) and btc_momentum >= btc_min_momentum
+        if position is not None:
+            if not btc_risk_on or ratio.iloc[index] >= float(current_ratio_middle):
+                pending_exit = True
+            continue
+
+        ratio_pullback = ratio.iloc[index] <= float(current_ratio_lower) or current_ratio_rsi <= ratio_rsi_threshold
+        if btc_risk_on and ratio_pullback:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=eth_candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def run_btc_lead_eth_lag_segment(
+    *,
+    eth_candles: list[Candle],
+    btc_candles: list[Candle],
+    leverage: int,
+    warmup_bars: int,
+    lead_lookback: int,
+    btc_return_threshold: float,
+    lag_gap: float,
+    max_hold_bars: int,
+    stop_loss_pct: float,
+    take_profit_pct: float,
+) -> SegmentResult:
+    equity = INITIAL_EQUITY
+    ending_equity = equity
+    peak_equity = equity
+    max_drawdown = 0.0
+    wins = 0
+    trades: list[dict[str, object]] = []
+    entries: list[dict[str, object]] = []
+    exits: list[dict[str, object]] = []
+    equity_curve: list[dict[str, float | int]] = []
+    position: dict[str, object] | None = None
+    pending_entry = False
+    pending_exit = False
+
+    for index in range(warmup_bars, len(eth_candles)):
+        candle = eth_candles[index]
+        if pending_exit and position is not None:
+            equity, won = _trade(
+                trades=trades,
+                exits=exits,
+                position=position,
+                candle=candle,
+                exit_price=candle.open,
+                leverage=leverage,
+            )
+            wins += 1 if won else 0
+            position = None
+            pending_exit = False
+        if pending_entry and position is None and equity > 0.0:
+            position = {
+                "side": "long",
+                "entry_time": candle.ts,
+                "entry_price": candle.open,
+                "entry_index": index,
+                "margin_used": equity,
+                "stop_price": candle.open * (1.0 - stop_loss_pct),
+                "take_profit_price": candle.open * (1.0 + take_profit_pct),
+            }
+            entries.append({"ts": candle.ts, "price": candle.open, "side": "long"})
+            pending_entry = False
+
+        current_equity = equity
+        if position is not None and index > int(position["entry_index"]):
+            if candle.low <= float(position["stop_price"]):
+                equity, won = _trade(
+                    trades=trades,
+                    exits=exits,
+                    position=position,
+                    candle=candle,
+                    exit_price=float(position["stop_price"]),
+                    leverage=leverage,
+                )
+                wins += 1 if won else 0
+                current_equity = equity
+                position = None
+            elif candle.high >= float(position["take_profit_price"]):
+                equity, won = _trade(
+                    trades=trades,
+                    exits=exits,
+                    position=position,
+                    candle=candle,
+                    exit_price=float(position["take_profit_price"]),
+                    leverage=leverage,
+                )
+                wins += 1 if won else 0
+                current_equity = equity
+                position = None
+
+        if position is not None:
+            current_equity = mark_to_market(
+                side="long",
+                margin_used=float(position["margin_used"]),
+                entry_price=float(position["entry_price"]),
+                mark_price=candle.close,
+                leverage=leverage,
+            )
+
+        peak_equity = max(peak_equity, current_equity)
+        max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
+        equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
+        ending_equity = current_equity
+        if index == len(eth_candles) - 1 or equity <= 0.0:
+            continue
+
+        if position is not None:
+            if index - int(position["entry_index"]) >= max_hold_bars:
+                pending_exit = True
+            continue
+
+        btc_return = btc_candles[index].close / btc_candles[index - lead_lookback].close - 1.0
+        eth_return = candle.close / eth_candles[index - lead_lookback].close - 1.0
+        if btc_return >= btc_return_threshold and btc_return - eth_return >= lag_gap:
+            pending_entry = True
+
+    trade_count = len(trades)
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=(ending_equity - INITIAL_EQUITY) / INITIAL_EQUITY,
+        win_rate=wins / trade_count if trade_count else 0.0,
+        max_drawdown=max_drawdown,
+        trades=trades,
+        open_position=position,
+        candles=eth_candles[warmup_bars:],
+        equity_curve=equity_curve,
+        entries=entries,
+        exits=exits,
+    )
+
+
+def build_candidates() -> list[Candidate]:
+    candidates: list[Candidate] = []
+    candidates.append(Candidate("bbmr-default", 69, lambda candles, leverage, warmup_bars: run_bbmr_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=BBMRConfig())))
+    candidates.append(Candidate("bbsb-default", 69, lambda candles, leverage, warmup_bars: run_bbsb_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=BBSBConfig())))
+    for entry_window in (8, 12, 20):
+        for exit_window in (4, 6, 10):
+            for stop in (0.004, 0.008, 0.012):
+                config = DonchianConfig(entry_window=entry_window, exit_window=exit_window, stop_loss_pct=stop)
+                candidates.append(Candidate(f"donchian-e{entry_window}-x{exit_window}-s{stop}", max(entry_window, exit_window), lambda candles, leverage, warmup_bars, config=config: run_donchian_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=config)))
+    for trend in (30, 50, 80):
+        for long_threshold, short_threshold in ((8.0, 92.0), (12.0, 88.0), (18.0, 82.0)):
+            config = RSI2Config(trend_sma=trend, rsi_length=2, rsi_long_threshold=long_threshold, rsi_short_threshold=short_threshold, exit_rsi=50.0)
+            candidates.append(Candidate(f"rsi2-t{trend}-l{long_threshold}-s{short_threshold}", max(trend, 3), lambda candles, leverage, warmup_bars, config=config: run_rsi2_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=config)))
+    for fast, slow in ((8, 21), (13, 34), (20, 50)):
+        for stop in (0.003, 0.006, 0.01):
+            config = EMAPullbackConfig(fast_ema=fast, slow_ema=slow, stop_buffer_pct=stop)
+            candidates.append(Candidate(f"ema-pullback-f{fast}-s{slow}-b{stop}", max(fast, slow), lambda candles, leverage, warmup_bars, config=config: run_ema_pullback_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=config)))
+    for lookback in (6, 10, 16):
+        for take, stop in ((0.004, 0.003), (0.006, 0.004), (0.01, 0.005)):
+            candidates.append(Candidate(f"range-momo-l{lookback}-tp{take}-sl{stop}", lookback, lambda candles, leverage, warmup_bars, lookback=lookback, take=take, stop=stop: run_range_momentum_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, lookback=lookback, take_profit_pct=take, stop_loss_pct=stop)))
+    for window in (24, 48, 72):
+        for entry_z in (1.5, 2.0, 2.5):
+            candidates.append(Candidate(f"vwap-revert-w{window}-z{entry_z}", window * 2, lambda candles, leverage, warmup_bars, window=window, entry_z=entry_z: run_vwap_reversion_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, window=window, entry_z=entry_z, exit_z=0.2, stop_loss_pct=0.006)))
+    return candidates
+
+
+def evaluate_candidate(candidate: Candidate, candles: list[Candle]) -> dict[str, object]:
+    sampled = sample_segments(
+        candles=candles,
+        segments=SEGMENTS,
+        window_size=WINDOW_SIZE,
+        warmup_bars=candidate.warmup_bars,
+    )
+    results = [
+        candidate.run(
+            candles=candles[segment.context_start : segment.report_end],
+            leverage=LEVERAGE,
+            warmup_bars=candidate.warmup_bars,
+        )
+        for segment in sampled
+    ]
+    returns = [result.total_return for result in results]
+    return {
+        "name": candidate.name,
+        "avg_return": sum(returns) / len(returns),
+        "median_return": float(pd.Series(returns).median()),
+        "worst_return": min(returns),
+        "best_return": max(returns),
+        "trades": sum(result.trade_count for result in results),
+        "win_rate": sum(result.win_rate for result in results) / len(results),
+        "max_drawdown": max(result.max_drawdown for result in results),
+    }
+
+
+def evaluate_candidate_all_windows(
+    *,
+    candidate: Candidate,
+    candles: list[Candle],
+    window_size: int,
+    leverage: int,
+) -> dict[str, object]:
+    rows = evaluate_candidate_window_rows(
+        candidate=candidate,
+        candles=candles,
+        window_size=window_size,
+        leverage=leverage,
+    )
+    return summarize_window_rows(rows, candidate.name)
+
+
+def evaluate_candidate_window_rows(
+    *,
+    candidate: Candidate,
+    candles: list[Candle],
+    window_size: int,
+    leverage: int,
+) -> list[dict[str, object]]:
+    block_size = candidate.warmup_bars + window_size
+    context_starts = list(range(0, len(candles) - block_size + 1, window_size))
+    rows: list[dict[str, object]] = []
+    for start in context_starts:
+        result = candidate.run(
+            candles=candles[start : start + block_size],
+            leverage=leverage,
+            warmup_bars=candidate.warmup_bars,
+        )
+        report_start = start + candidate.warmup_bars
+        report_end = start + block_size - 1
+        rows.append(
+            {
+                "window_start_ts": candles[report_start].ts,
+                "window_end_ts": candles[report_end].ts,
+                "total_return": result.total_return,
+                "trade_count": result.trade_count,
+                "win_rate": result.win_rate,
+                "max_drawdown": result.max_drawdown,
+                "trades": result.trades,
+            }
+        )
+    return rows
+
+
+def evaluate_pair_candidate_window_rows(
+    *,
+    candidate: PairCandidate,
+    eth_candles: list[Candle],
+    btc_candles: list[Candle],
+    window_size: int,
+    leverage: int,
+) -> list[dict[str, object]]:
+    block_size = candidate.warmup_bars + window_size
+    context_starts = list(range(0, len(eth_candles) - block_size + 1, window_size))
+    rows: list[dict[str, object]] = []
+    for start in context_starts:
+        result = candidate.run(
+            eth_candles=eth_candles[start : start + block_size],
+            btc_candles=btc_candles[start : start + block_size],
+            leverage=leverage,
+            warmup_bars=candidate.warmup_bars,
+        )
+        report_start = start + candidate.warmup_bars
+        report_end = start + block_size - 1
+        rows.append(
+            {
+                "window_start_ts": eth_candles[report_start].ts,
+                "window_end_ts": eth_candles[report_end].ts,
+                "total_return": result.total_return,
+                "trade_count": result.trade_count,
+                "win_rate": result.win_rate,
+                "max_drawdown": result.max_drawdown,
+                "trades": result.trades,
+            }
+        )
+    return rows
+
+
+def summarize_window_rows(rows: list[dict[str, object]], name: str = "") -> dict[str, object]:
+    returns = [float(row["total_return"]) for row in rows]
+    trade_returns = [
+        float(trade["return_pct"]) / 100.0
+        for row in rows
+        for trade in row["trades"]
+    ]
+    winning_trade_returns = [value for value in trade_returns if value > 0.0]
+    losing_trade_returns = [value for value in trade_returns if value < 0.0]
+    avg_win_return = sum(winning_trade_returns) / len(winning_trade_returns) if winning_trade_returns else 0.0
+    avg_loss_return_abs = abs(sum(losing_trade_returns) / len(losing_trade_returns)) if losing_trade_returns else 0.0
+    gross_profit = sum(winning_trade_returns)
+    gross_loss_abs = abs(sum(losing_trade_returns))
+    series = pd.Series(returns, dtype=float)
+    sample_count = len(returns)
+    std = float(series.std(ddof=1)) if sample_count > 1 else 0.0
+    ci_half_width = 1.96 * std / sqrt(sample_count) if sample_count > 1 else 0.0
+    return {
+        "name": name,
+        "sample_count": sample_count,
+        "avg_return": float(series.mean()),
+        "ci95_low": float(series.mean() - ci_half_width),
+        "ci95_high": float(series.mean() + ci_half_width),
+        "median_return": float(series.median()),
+        "positive_window_rate": float((series > 0).mean()),
+        "worst_return": float(series.min()),
+        "p10_return": float(series.quantile(0.10)),
+        "p90_return": float(series.quantile(0.90)),
+        "best_return": float(series.max()),
+        "trades": sum(int(row["trade_count"]) for row in rows),
+        "avg_trades_per_window": sum(int(row["trade_count"]) for row in rows) / sample_count,
+        "win_rate": sum(float(row["win_rate"]) for row in rows) / sample_count,
+        "trade_win_rate": len(winning_trade_returns) / len(trade_returns) if trade_returns else 0.0,
+        "avg_trade_return": sum(trade_returns) / len(trade_returns) if trade_returns else 0.0,
+        "avg_win_return": avg_win_return,
+        "avg_loss_return_abs": avg_loss_return_abs,
+        "payoff_ratio": avg_win_return / avg_loss_return_abs if avg_loss_return_abs else 0.0,
+        "profit_factor": gross_profit / gross_loss_abs if gross_loss_abs else 0.0,
+        "expectancy_per_trade": sum(trade_returns) / len(trade_returns) if trade_returns else 0.0,
+        "max_drawdown": max(float(row["max_drawdown"]) for row in rows),
+        "return_drawdown_ratio": float(series.mean()) / max(float(row["max_drawdown"]) for row in rows) if max(float(row["max_drawdown"]) for row in rows) else 0.0,
+    }
+
+
+def sort_robust_results(frame: pd.DataFrame) -> pd.DataFrame:
+    return frame.sort_values(["ci95_low", "avg_return"], ascending=False)
+
+
+def add_cost_metrics(frame: pd.DataFrame, roundtrip_cost_on_margin: float) -> pd.DataFrame:
+    frame = frame.copy()
+    cost = frame["avg_trades_per_window"] * roundtrip_cost_on_margin
+    frame["roundtrip_cost_on_margin"] = roundtrip_cost_on_margin
+    frame["net_avg_return"] = frame["avg_return"] - cost
+    frame["net_ci95_low"] = frame["ci95_low"] - cost
+    frame["net_ci95_high"] = frame["ci95_high"] - cost
+    frame["breakeven_roundtrip_cost_on_margin"] = frame["avg_return"] / frame["avg_trades_per_window"]
+    return frame
+
+
+def sort_cost_results(frame: pd.DataFrame) -> pd.DataFrame:
+    return frame.sort_values(["net_ci95_low", "net_avg_return"], ascending=False)
+
+
+def max_drawdown_from_equity(equity_values: list[float]) -> float:
+    peak = equity_values[0]
+    max_drawdown = 0.0
+    for equity in equity_values:
+        peak = max(peak, equity)
+        if peak > 0.0:
+            max_drawdown = max(max_drawdown, (peak - equity) / peak)
+    return max_drawdown
+
+
+def cost_adjusted_trade_equity_frame(result: SegmentResult, roundtrip_cost_on_margin: float) -> pd.DataFrame:
+    rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": INITIAL_EQUITY}]
+    equity = INITIAL_EQUITY
+    for trade in result.trades:
+        equity *= 1.0 + float(trade["return_pct"]) / 100.0 - roundtrip_cost_on_margin
+        rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
+    return pd.DataFrame(rows)
+
+
+def annualized_metrics_from_equity(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
+    years = (last_ts - first_ts) / 86_400_000 / 365
+    total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
+    annualized_return = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
+    max_drawdown = max_drawdown_from_equity([float(value) for value in frame["equity"]])
+    daily = frame.set_index("ts")["equity"].resample("1D").last().ffill()
+    daily_returns = daily.pct_change().dropna()
+    daily_std = float(daily_returns.std(ddof=1)) if len(daily_returns) > 1 else 0.0
+    sharpe = float(daily_returns.mean()) / daily_std * sqrt(365) if daily_std else 0.0
+    return {
+        "net_total_return": total_return,
+        "net_annualized_return": annualized_return,
+        "net_max_drawdown": max_drawdown,
+        "net_calmar": annualized_return / max_drawdown if max_drawdown else 0.0,
+        "net_sharpe_daily": sharpe,
+    }
+
+
+def recent_horizon_metrics_from_equity(
+    frame: pd.DataFrame,
+    last_ts: int,
+    horizons: tuple[tuple[str, pd.DateOffset], ...],
+) -> pd.DataFrame:
+    rows: list[dict[str, object]] = []
+    end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
+    for label, offset in horizons:
+        cutoff = end_time - offset
+        before_cutoff = frame[frame["ts"] <= cutoff]
+        if len(before_cutoff):
+            start_equity = float(before_cutoff["equity"].iloc[-1])
+            start_time = cutoff
+            after_cutoff = frame[frame["ts"] > cutoff]
+            horizon_frame = pd.concat(
+                [
+                    pd.DataFrame([{"ts": start_time, "equity": start_equity}]),
+                    after_cutoff[["ts", "equity"]],
+                ],
+                ignore_index=True,
+            )
+        else:
+            horizon_frame = frame[["ts", "equity"]].copy()
+            start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
+
+        metrics = annualized_metrics_from_equity(
+            horizon_frame,
+            int(start_time.timestamp() * 1000),
+            last_ts,
+        )
+        rows.append(
+            {
+                "horizon": label,
+                "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
+                "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
+                "horizon_days": (end_time - start_time).total_seconds() / 86_400,
+                **metrics,
+            }
+        )
+    return pd.DataFrame(rows)
+
+
+def build_rsi2_candidate(trend: int, long_threshold: float, short_threshold: float) -> Candidate:
+    config = RSI2Config(
+        trend_sma=trend,
+        rsi_length=2,
+        rsi_long_threshold=long_threshold,
+        rsi_short_threshold=short_threshold,
+        exit_rsi=50.0,
+    )
+    return Candidate(
+        f"rsi2-t{trend}-l{long_threshold}-s{short_threshold}",
+        max(trend, 3),
+        lambda candles, leverage, warmup_bars, config=config: run_rsi2_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            config=config,
+        ),
+    )
+
+
+def build_rsi2_side_candidate(
+    trend: int,
+    long_threshold: float,
+    short_threshold: float,
+    exit_rsi: float,
+    side_mode: str,
+) -> Candidate:
+    config = RSI2Config(
+        trend_sma=trend,
+        rsi_length=2,
+        rsi_long_threshold=long_threshold,
+        rsi_short_threshold=short_threshold,
+        exit_rsi=exit_rsi,
+    )
+    return Candidate(
+        f"rsi2-{side_mode}-t{trend}-l{long_threshold}-s{short_threshold}-x{exit_rsi}",
+        max(trend, 3),
+        lambda candles, leverage, warmup_bars, config=config, side_mode=side_mode: run_rsi2_side_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            config=config,
+            side_mode=side_mode,
+        ),
+    )
+
+
+def build_rsi2_long_guarded_candidate(
+    trend: int,
+    rsi_threshold: float,
+    exit_rsi: float,
+    stop_loss_pct: float,
+    max_hold_bars: int,
+) -> Candidate:
+    return Candidate(
+        f"rsi2-long-guarded-t{trend}-l{rsi_threshold}-x{exit_rsi}-sl{stop_loss_pct}-mh{max_hold_bars}",
+        max(trend, 3),
+        lambda candles, leverage, warmup_bars, trend=trend, rsi_threshold=rsi_threshold, exit_rsi=exit_rsi, stop_loss_pct=stop_loss_pct, max_hold_bars=max_hold_bars: run_rsi2_long_guarded_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            trend_sma=trend,
+            rsi_threshold=rsi_threshold,
+            exit_rsi=exit_rsi,
+            stop_loss_pct=stop_loss_pct,
+            max_hold_bars=max_hold_bars,
+        ),
+    )
+
+
+def build_ma_cross_candidate(fast: int, slow: int, side_mode: str) -> Candidate:
+    return Candidate(
+        f"ma-cross-{side_mode}-f{fast}-s{slow}",
+        slow,
+        lambda candles, leverage, warmup_bars, fast=fast, slow=slow, side_mode=side_mode: run_ma_cross_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            fast=fast,
+            slow=slow,
+            side_mode=side_mode,
+        ),
+    )
+
+
+def build_trend_rsi_bb_long_candidate(
+    trend_sma: int,
+    band_length: int,
+    std_multiplier: float,
+    rsi_threshold: float,
+    exit_rsi: float,
+    stop_loss_pct: float,
+) -> Candidate:
+    return Candidate(
+        f"trend-rsi-bb-long-t{trend_sma}-b{band_length}-m{std_multiplier}-r{rsi_threshold}-x{exit_rsi}-sl{stop_loss_pct}",
+        max(trend_sma, band_length, 3),
+        lambda candles, leverage, warmup_bars, trend_sma=trend_sma, band_length=band_length, std_multiplier=std_multiplier, rsi_threshold=rsi_threshold, exit_rsi=exit_rsi, stop_loss_pct=stop_loss_pct: run_trend_rsi_bb_long_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            trend_sma=trend_sma,
+            band_length=band_length,
+            std_multiplier=std_multiplier,
+            rsi_threshold=rsi_threshold,
+            exit_rsi=exit_rsi,
+            stop_loss_pct=stop_loss_pct,
+        ),
+    )
+
+
+def build_regime_hybrid_candidate(
+    trend_sma: int,
+    regime_lookback: int,
+    neutral_ma_distance: float,
+    rsi_long_threshold: float,
+    rsi_exit: float,
+    bb_std: float,
+    stop_loss_pct: float,
+) -> Candidate:
+    return Candidate(
+        f"regime-hybrid-t{trend_sma}-r{regime_lookback}-n{neutral_ma_distance}-l{rsi_long_threshold}-x{rsi_exit}-m{bb_std}-sl{stop_loss_pct}",
+        max(trend_sma, regime_lookback, 20, 50, 3),
+        lambda candles, leverage, warmup_bars, trend_sma=trend_sma, regime_lookback=regime_lookback, neutral_ma_distance=neutral_ma_distance, rsi_long_threshold=rsi_long_threshold, rsi_exit=rsi_exit, bb_std=bb_std, stop_loss_pct=stop_loss_pct: run_regime_hybrid_segment(
+            candles=candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            trend_sma=trend_sma,
+            regime_lookback=regime_lookback,
+            neutral_ma_distance=neutral_ma_distance,
+            rsi_long_threshold=rsi_long_threshold,
+            rsi_exit=rsi_exit,
+            bb_length=20,
+            bb_std=bb_std,
+            bb_bandwidth_lookback=50,
+            stop_loss_pct=stop_loss_pct,
+        ),
+    )
+
+
+def build_eth_btc_rsi_filter_candidate(
+    eth_trend_sma: int,
+    eth_rsi_threshold: float,
+    eth_exit_rsi: float,
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+) -> PairCandidate:
+    return PairCandidate(
+        f"eth-btc-rsi-filter-et{eth_trend_sma}-l{eth_rsi_threshold}-x{eth_exit_rsi}-bt{btc_trend_sma}-bm{btc_momentum_lookback}-br{btc_min_momentum}",
+        max(eth_trend_sma, btc_trend_sma, btc_momentum_lookback, 3),
+        lambda eth_candles, btc_candles, leverage, warmup_bars, eth_trend_sma=eth_trend_sma, eth_rsi_threshold=eth_rsi_threshold, eth_exit_rsi=eth_exit_rsi, btc_trend_sma=btc_trend_sma, btc_momentum_lookback=btc_momentum_lookback, btc_min_momentum=btc_min_momentum: run_eth_btc_rsi_filter_segment(
+            eth_candles=eth_candles,
+            btc_candles=btc_candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            eth_trend_sma=eth_trend_sma,
+            eth_rsi_threshold=eth_rsi_threshold,
+            eth_exit_rsi=eth_exit_rsi,
+            btc_trend_sma=btc_trend_sma,
+            btc_momentum_lookback=btc_momentum_lookback,
+            btc_min_momentum=btc_min_momentum,
+        ),
+    )
+
+
+def build_eth_btc_shock_filter_candidate(
+    eth_trend_sma: int,
+    eth_rsi_threshold: float,
+    eth_exit_rsi: float,
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+    btc_shock_lookback: int,
+    btc_max_realized_vol: float,
+    btc_max_drawdown: float,
+) -> PairCandidate:
+    return PairCandidate(
+        f"eth-btc-shock-filter-et{eth_trend_sma}-l{eth_rsi_threshold}-x{eth_exit_rsi}-bt{btc_trend_sma}-bm{btc_momentum_lookback}-br{btc_min_momentum}-sw{btc_shock_lookback}-sv{btc_max_realized_vol}-sd{btc_max_drawdown}",
+        max(eth_trend_sma, btc_trend_sma, btc_momentum_lookback, btc_shock_lookback + 1, 3),
+        lambda eth_candles, btc_candles, leverage, warmup_bars, eth_trend_sma=eth_trend_sma, eth_rsi_threshold=eth_rsi_threshold, eth_exit_rsi=eth_exit_rsi, btc_trend_sma=btc_trend_sma, btc_momentum_lookback=btc_momentum_lookback, btc_min_momentum=btc_min_momentum, btc_shock_lookback=btc_shock_lookback, btc_max_realized_vol=btc_max_realized_vol, btc_max_drawdown=btc_max_drawdown: run_eth_btc_shock_filter_segment(
+            eth_candles=eth_candles,
+            btc_candles=btc_candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            eth_trend_sma=eth_trend_sma,
+            eth_rsi_threshold=eth_rsi_threshold,
+            eth_exit_rsi=eth_exit_rsi,
+            btc_trend_sma=btc_trend_sma,
+            btc_momentum_lookback=btc_momentum_lookback,
+            btc_min_momentum=btc_min_momentum,
+            btc_shock_lookback=btc_shock_lookback,
+            btc_max_realized_vol=btc_max_realized_vol,
+            btc_max_drawdown=btc_max_drawdown,
+        ),
+    )
+
+
+def build_eth_btc_ratio_pullback_candidate(
+    btc_trend_sma: int,
+    btc_momentum_lookback: int,
+    btc_min_momentum: float,
+    ratio_length: int,
+    ratio_std: float,
+    ratio_rsi_threshold: float,
+    stop_loss_pct: float,
+) -> PairCandidate:
+    return PairCandidate(
+        f"eth-btc-ratio-pullback-bt{btc_trend_sma}-bm{btc_momentum_lookback}-br{btc_min_momentum}-rl{ratio_length}-rs{ratio_std}-rr{ratio_rsi_threshold}-sl{stop_loss_pct}",
+        max(btc_trend_sma, btc_momentum_lookback, ratio_length, 3),
+        lambda eth_candles, btc_candles, leverage, warmup_bars, btc_trend_sma=btc_trend_sma, btc_momentum_lookback=btc_momentum_lookback, btc_min_momentum=btc_min_momentum, ratio_length=ratio_length, ratio_std=ratio_std, ratio_rsi_threshold=ratio_rsi_threshold, stop_loss_pct=stop_loss_pct: run_eth_btc_ratio_pullback_segment(
+            eth_candles=eth_candles,
+            btc_candles=btc_candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            btc_trend_sma=btc_trend_sma,
+            btc_momentum_lookback=btc_momentum_lookback,
+            btc_min_momentum=btc_min_momentum,
+            ratio_length=ratio_length,
+            ratio_std=ratio_std,
+            ratio_rsi_threshold=ratio_rsi_threshold,
+            stop_loss_pct=stop_loss_pct,
+        ),
+    )
+
+
+def build_btc_lead_eth_lag_candidate(
+    lead_lookback: int,
+    btc_return_threshold: float,
+    lag_gap: float,
+    max_hold_bars: int,
+    stop_loss_pct: float,
+    take_profit_pct: float,
+) -> PairCandidate:
+    return PairCandidate(
+        f"btc-lead-eth-lag-lb{lead_lookback}-br{btc_return_threshold}-gap{lag_gap}-mh{max_hold_bars}-sl{stop_loss_pct}-tp{take_profit_pct}",
+        lead_lookback,
+        lambda eth_candles, btc_candles, leverage, warmup_bars, lead_lookback=lead_lookback, btc_return_threshold=btc_return_threshold, lag_gap=lag_gap, max_hold_bars=max_hold_bars, stop_loss_pct=stop_loss_pct, take_profit_pct=take_profit_pct: run_btc_lead_eth_lag_segment(
+            eth_candles=eth_candles,
+            btc_candles=btc_candles,
+            leverage=leverage,
+            warmup_bars=warmup_bars,
+            lead_lookback=lead_lookback,
+            btc_return_threshold=btc_return_threshold,
+            lag_gap=lag_gap,
+            max_hold_bars=max_hold_bars,
+            stop_loss_pct=stop_loss_pct,
+            take_profit_pct=take_profit_pct,
+        ),
+    )
+
+
+def history_bars_for_years(bar: str, years: float) -> int:
+    if not bar.endswith("m"):
+        raise ValueError("minute bar is required")
+    minutes = int(bar[:-1])
+    if minutes <= 0:
+        raise ValueError("minute bar is required")
+    return int(MINUTES_PER_YEAR * years / minutes)
+
+
+def build_strategy_timeframe_candidates() -> list[Candidate]:
+    return [
+        Candidate("bbmr-default", 69, lambda candles, leverage, warmup_bars: run_bbmr_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=BBMRConfig())),
+        Candidate("bbsb-default", 69, lambda candles, leverage, warmup_bars: run_bbsb_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=BBSBConfig())),
+        Candidate("donchian-e12-x6-s0.008", 12, lambda candles, leverage, warmup_bars: run_donchian_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=DonchianConfig(entry_window=12, exit_window=6, stop_loss_pct=0.008))),
+        build_rsi2_candidate(50, 3.0, 97.0),
+        Candidate("ema-pullback-f13-s34-b0.006", 34, lambda candles, leverage, warmup_bars: run_ema_pullback_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, config=EMAPullbackConfig(fast_ema=13, slow_ema=34, stop_buffer_pct=0.006))),
+        Candidate("range-momo-l10-tp0.006-sl0.004", 10, lambda candles, leverage, warmup_bars: run_range_momentum_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, lookback=10, take_profit_pct=0.006, stop_loss_pct=0.004)),
+        Candidate("vwap-revert-w72-z2.0-sl0.006", 144, lambda candles, leverage, warmup_bars: run_vwap_reversion_segment(candles=candles, leverage=leverage, warmup_bars=warmup_bars, window=72, entry_z=2.0, exit_z=0.2, stop_loss_pct=0.006)),
+    ]
+
+
+def summarize_periods(frame: pd.DataFrame, period: str, roundtrip_cost_on_margin: float) -> pd.DataFrame:
+    period_frame = frame.copy()
+    period_frame["period"] = pd.to_datetime(period_frame["window_end_ts"], unit="ms", utc=True).dt.tz_localize(None).dt.to_period(period).astype(str)
+    grouped = (
+        period_frame.groupby("period", as_index=False)
+        .agg(
+            window_count=("total_return", "size"),
+            avg_return=("total_return", "mean"),
+            median_return=("total_return", "median"),
+            positive_window_rate=("total_return", lambda values: float((values > 0.0).mean())),
+            worst_return=("total_return", "min"),
+            best_return=("total_return", "max"),
+            trades=("trade_count", "sum"),
+            avg_trades_per_window=("trade_count", "mean"),
+            avg_window_win_rate=("win_rate", "mean"),
+            max_drawdown=("max_drawdown", "max"),
+        )
+        .sort_values("period")
+    )
+    grouped["net_avg_return"] = grouped["avg_return"] - grouped["avg_trades_per_window"] * roundtrip_cost_on_margin
+    return grouped
+
+
+def summarize_equity_periods(result: SegmentResult, period: str) -> pd.DataFrame:
+    frame = pd.DataFrame(result.equity_curve)
+    frame["period"] = pd.to_datetime(frame["ts"], unit="ms", utc=True).dt.tz_localize(None).dt.to_period(period).astype(str)
+    grouped = (
+        frame.groupby("period", as_index=False)
+        .agg(
+            start_equity=("equity", "first"),
+            end_equity=("equity", "last"),
+            min_equity=("equity", "min"),
+            max_equity=("equity", "max"),
+            bars=("equity", "size"),
+        )
+        .sort_values("period")
+    )
+    grouped["return"] = grouped["end_equity"] / grouped["start_equity"] - 1.0
+    grouped["drawdown_from_period_high"] = (grouped["max_equity"] - grouped["min_equity"]) / grouped["max_equity"]
+    return grouped
+
+
+def summarize_cost_adjusted_trade_equity_periods(
+    result: SegmentResult,
+    period: str,
+    roundtrip_cost_on_margin: float,
+) -> pd.DataFrame:
+    frame = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+    frame["period"] = frame["ts"].dt.tz_localize(None).dt.to_period(period).astype(str)
+    grouped = (
+        frame.groupby("period", as_index=False)
+        .agg(
+            start_equity=("equity", "first"),
+            end_equity=("equity", "last"),
+            min_equity=("equity", "min"),
+            max_equity=("equity", "max"),
+            trades=("equity", lambda values: max(len(values) - 1, 0)),
+        )
+        .sort_values("period")
+    )
+    grouped["return"] = grouped["end_equity"] / grouped["start_equity"] - 1.0
+    grouped["drawdown_from_period_high"] = (grouped["max_equity"] - grouped["min_equity"]) / grouped["max_equity"]
+    return grouped
+
+
+def add_market_regime_columns(candles: list[Candle], rows: list[dict[str, object]], roundtrip_cost_on_margin: float) -> pd.DataFrame:
+    index_by_ts = {candle.ts: index for index, candle in enumerate(candles)}
+    output_rows: list[dict[str, object]] = []
+    closes = pd.Series([candle.close for candle in candles], dtype=float)
+    long_ma = closes.rolling(240).mean().tolist()
+    for row in rows:
+        start = index_by_ts[int(row["window_start_ts"])]
+        end = index_by_ts[int(row["window_end_ts"])]
+        window_closes = closes.iloc[start : end + 1]
+        returns = window_closes.pct_change().dropna()
+        ma_value = long_ma[end]
+        close_value = float(window_closes.iloc[-1])
+        output_rows.append(
+            {
+                **{key: value for key, value in row.items() if key != "trades"},
+                "net_return": float(row["total_return"]) - int(row["trade_count"]) * roundtrip_cost_on_margin,
+                "market_return": close_value / float(window_closes.iloc[0]) - 1.0,
+                "realized_vol": float(returns.std(ddof=1)) if len(returns) > 1 else 0.0,
+                "ma240_distance": close_value / float(ma_value) - 1.0 if ma_value == ma_value and ma_value else 0.0,
+            }
+        )
+    frame = pd.DataFrame(output_rows)
+    frame["market_return_bucket"] = pd.qcut(frame["market_return"], 3, labels=["down", "flat", "up"], duplicates="drop")
+    frame["realized_vol_bucket"] = pd.qcut(frame["realized_vol"], 3, labels=["low", "mid", "high"], duplicates="drop")
+    frame["ma240_distance_bucket"] = pd.qcut(frame["ma240_distance"], 3, labels=["below", "near", "above"], duplicates="drop")
+    return frame
+
+
+def summarize_regime_columns(frame: pd.DataFrame) -> pd.DataFrame:
+    summaries: list[pd.DataFrame] = []
+    for column in ("market_return_bucket", "realized_vol_bucket", "ma240_distance_bucket"):
+        grouped = (
+            frame.groupby(["symbol", "bar", "name", column], observed=True, as_index=False)
+            .agg(
+                sample_count=("net_return", "size"),
+                avg_net_return=("net_return", "mean"),
+                median_net_return=("net_return", "median"),
+                positive_window_rate=("net_return", lambda values: float((values > 0.0).mean())),
+                worst_net_return=("net_return", "min"),
+                best_net_return=("net_return", "max"),
+                avg_trades=("trade_count", "mean"),
+                avg_market_return=("market_return", "mean"),
+                avg_realized_vol=("realized_vol", "mean"),
+                avg_ma240_distance=("ma240_distance", "mean"),
+            )
+            .rename(columns={column: "regime"})
+        )
+        grouped.insert(3, "regime_type", column.removesuffix("_bucket"))
+        summaries.append(grouped)
+    return pd.concat(summaries, ignore_index=True).sort_values(
+        ["name", "regime_type", "avg_net_return"],
+        ascending=[True, True, False],
+    )
+
+
+def main() -> int:
+    client = OkxClient()
+    candidates = build_candidates()
+    rows: list[dict[str, object]] = []
+    for symbol in SYMBOLS:
+        for bar in BARS:
+            candles = get_candles_cached(client, symbol, bar, HISTORY_LIMIT)
+            for candidate in candidates:
+                metrics = evaluate_candidate(candidate, candles)
+                rows.append({"symbol": symbol, "bar": bar, **metrics})
+            print(f"done {symbol} {bar}")
+    frame = pd.DataFrame(rows)
+    frame["score"] = frame["avg_return"] - frame["max_drawdown"] * 0.25
+    columns = ["symbol", "bar", "name", "avg_return", "median_return", "worst_return", "best_return", "trades", "win_rate", "max_drawdown", "score"]
+    print(frame.sort_values(["avg_return", "median_return"], ascending=False)[columns].head(30).to_string(index=False))
+    frame.to_csv("ultrashort-exploration.csv", index=False)
+    return 0
+
+
+def focus_vwap() -> int:
+    client = OkxClient()
+    candles = get_candles_cached(client, "ETH-USDT-SWAP", "3m", HISTORY_LIMIT)
+    rows: list[dict[str, object]] = []
+    for seed in (3, 7, 11, 17, 23):
+        for window in (56, 64, 72, 80, 96):
+            for entry_z in (1.6, 1.8, 2.0, 2.2, 2.4):
+                for stop in (0.004, 0.006, 0.008):
+                    warmup_bars = window * 2
+                    sampled = sample_segments(
+                        candles=candles,
+                        segments=SEGMENTS,
+                        window_size=WINDOW_SIZE,
+                        warmup_bars=warmup_bars,
+                        seed=seed,
+                    )
+                    results = [
+                        run_vwap_reversion_segment(
+                            candles=candles[segment.context_start : segment.report_end],
+                            leverage=LEVERAGE,
+                            warmup_bars=warmup_bars,
+                            window=window,
+                            entry_z=entry_z,
+                            exit_z=0.2,
+                            stop_loss_pct=stop,
+                        )
+                        for segment in sampled
+                    ]
+                    returns = [result.total_return for result in results]
+                    rows.append(
+                        {
+                            "seed": seed,
+                            "window": window,
+                            "entry_z": entry_z,
+                            "stop": stop,
+                            "avg_return": sum(returns) / len(returns),
+                            "median_return": float(pd.Series(returns).median()),
+                            "worst_return": min(returns),
+                            "best_return": max(returns),
+                            "trades": sum(result.trade_count for result in results),
+                            "win_rate": sum(result.win_rate for result in results) / len(results),
+                            "max_drawdown": max(result.max_drawdown for result in results),
+                        }
+                    )
+    frame = pd.DataFrame(rows)
+    grouped = (
+        frame.groupby(["window", "entry_z", "stop"], as_index=False)
+        .agg(
+            avg_return=("avg_return", "mean"),
+            median_return=("median_return", "mean"),
+            worst_seed_avg=("avg_return", "min"),
+            worst_window_return=("worst_return", "min"),
+            trades=("trades", "mean"),
+            win_rate=("win_rate", "mean"),
+            max_drawdown=("max_drawdown", "max"),
+        )
+        .sort_values(["avg_return", "worst_seed_avg"], ascending=False)
+    )
+    print(grouped.head(30).to_string(index=False))
+    grouped.to_csv("ultrashort-vwap-focus.csv", index=False)
+    return 0
+
+
+def robust_vwap(history_limit: int, window_size: int) -> int:
+    client = OkxClient()
+    rows: list[dict[str, object]] = []
+    candidates = [
+        Candidate(
+            f"vwap-revert-w{window}-z{entry_z}-sl{stop}",
+            window * 2,
+            lambda candles, leverage, warmup_bars, window=window, entry_z=entry_z, stop=stop: run_vwap_reversion_segment(
+                candles=candles,
+                leverage=leverage,
+                warmup_bars=warmup_bars,
+                window=window,
+                entry_z=entry_z,
+                exit_z=0.2,
+                stop_loss_pct=stop,
+            ),
+        )
+        for window in (56, 64, 72, 80, 96)
+        for entry_z in (1.6, 1.8, 2.0, 2.2, 2.4)
+        for stop in (0.004, 0.006, 0.008)
+    ]
+    for symbol in SYMBOLS:
+        candles = get_candles_cached(client, symbol, "3m", history_limit)
+        for candidate in candidates:
+            metrics = evaluate_candidate_all_windows(
+                candidate=candidate,
+                candles=candles,
+                window_size=window_size,
+                leverage=LEVERAGE,
+            )
+            rows.append({"symbol": symbol, "bar": "3m", "history_bars": len(candles), **metrics})
+        print(f"done robust {symbol} 3m {len(candles)} bars")
+    frame = pd.DataFrame(rows)
+    columns = [
+        "symbol",
+        "bar",
+        "history_bars",
+        "name",
+        "sample_count",
+        "avg_return",
+        "ci95_low",
+        "ci95_high",
+        "median_return",
+        "positive_window_rate",
+        "worst_return",
+        "p10_return",
+        "p90_return",
+        "best_return",
+        "trades",
+        "avg_trades_per_window",
+        "win_rate",
+        "trade_win_rate",
+        "avg_trade_return",
+        "avg_win_return",
+        "avg_loss_return_abs",
+        "payoff_ratio",
+        "profit_factor",
+        "expectancy_per_trade",
+        "max_drawdown",
+        "return_drawdown_ratio",
+    ]
+    frame = sort_robust_results(frame)
+    print(GROSS_RETURN_NOTE)
+    print(frame[columns].head(30).to_string(index=False))
+    frame.to_csv("ultrashort-vwap-robust.csv", index=False)
+    return 0
+
+
+def robust_all(history_limit: int, window_size: int) -> int:
+    client = OkxClient()
+    candidates = build_candidates()
+    rows: list[dict[str, object]] = []
+    for symbol in SYMBOLS:
+        for bar in BARS:
+            candles = get_candles_cached(client, symbol, bar, history_limit)
+            for candidate in candidates:
+                metrics = evaluate_candidate_all_windows(
+                    candidate=candidate,
+                    candles=candles,
+                    window_size=window_size,
+                    leverage=LEVERAGE,
+                )
+                rows.append({"symbol": symbol, "bar": bar, "history_bars": len(candles), **metrics})
+            print(f"done robust all {symbol} {bar} {len(candles)} bars")
+    frame = pd.DataFrame(rows)
+    columns = [
+        "symbol",
+        "bar",
+        "history_bars",
+        "name",
+        "sample_count",
+        "avg_return",
+        "ci95_low",
+        "ci95_high",
+        "median_return",
+        "positive_window_rate",
+        "worst_return",
+        "p10_return",
+        "p90_return",
+        "best_return",
+        "trades",
+        "avg_trades_per_window",
+        "win_rate",
+        "trade_win_rate",
+        "avg_trade_return",
+        "avg_win_return",
+        "avg_loss_return_abs",
+        "payoff_ratio",
+        "profit_factor",
+        "expectancy_per_trade",
+        "max_drawdown",
+        "return_drawdown_ratio",
+    ]
+    frame = sort_robust_results(frame)
+    print(GROSS_RETURN_NOTE)
+    print(frame[columns].head(40).to_string(index=False))
+    frame.to_csv("ultrashort-robust-all.csv", index=False)
+    return 0
+
+
+def robust_rsi2_cost_search(history_limit: int, window_size: int, roundtrip_cost_on_margin: float) -> int:
+    client = OkxClient()
+    candidates = [
+        build_rsi2_candidate(trend, long_threshold, short_threshold)
+        for trend in (30, 50, 80, 120, 160, 240)
+        for long_threshold, short_threshold in (
+            (3.0, 97.0),
+            (5.0, 95.0),
+            (8.0, 92.0),
+            (10.0, 90.0),
+            (12.0, 88.0),
+            (15.0, 85.0),
+            (18.0, 82.0),
+        )
+    ]
+    rows: list[dict[str, object]] = []
+    for symbol in SYMBOLS:
+        for bar in ("3m", "5m", "15m"):
+            candles = get_candles_cached(client, symbol, bar, history_limit)
+            for candidate in candidates:
+                metrics = evaluate_candidate_all_windows(
+                    candidate=candidate,
+                    candles=candles,
+                    window_size=window_size,
+                    leverage=LEVERAGE,
+                )
+                rows.append({"symbol": symbol, "bar": bar, "history_bars": len(candles), **metrics})
+            print(f"done robust rsi2 cost {symbol} {bar} {len(candles)} bars")
+    frame = sort_cost_results(add_cost_metrics(pd.DataFrame(rows), roundtrip_cost_on_margin))
+    columns = [
+        "symbol",
+        "bar",
+        "history_bars",
+        "name",
+        "sample_count",
+        "avg_return",
+        "ci95_low",
+        "avg_trades_per_window",
+        "breakeven_roundtrip_cost_on_margin",
+        "roundtrip_cost_on_margin",
+        "net_avg_return",
+        "net_ci95_low",
+        "net_ci95_high",
+        "median_return",
+        "positive_window_rate",
+        "worst_return",
+        "trade_win_rate",
+        "avg_trade_return",
+        "avg_win_return",
+        "avg_loss_return_abs",
+        "payoff_ratio",
+        "profit_factor",
+        "expectancy_per_trade",
+        "max_drawdown",
+        "return_drawdown_ratio",
+    ]
+    print(GROSS_RETURN_NOTE)
+    print(frame[columns].head(40).to_string(index=False))
+    frame.to_csv("ultrashort-rsi2-cost-search.csv", index=False)
+    return 0
+
+
+def rsi2_period_analysis(
+    *,
+    symbol: str,
+    bar: str,
+    history_limit: int,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+    trend: int,
+    long_threshold: float,
+    short_threshold: float,
+) -> int:
+    client = OkxClient()
+    candles = get_candles_cached(client, symbol, bar, history_limit)
+    candidate = build_rsi2_candidate(trend, long_threshold, short_threshold)
+    rows = evaluate_candidate_window_rows(
+        candidate=candidate,
+        candles=candles,
+        window_size=window_size,
+        leverage=LEVERAGE,
+    )
+    window_frame = pd.DataFrame(rows).drop(columns=["trades"])
+    window_frame["window_start"] = pd.to_datetime(window_frame["window_start_ts"], unit="ms", utc=True).dt.strftime("%Y-%m-%d %H:%M")
+    window_frame["window_end"] = pd.to_datetime(window_frame["window_end_ts"], unit="ms", utc=True).dt.strftime("%Y-%m-%d %H:%M")
+    window_frame["net_return"] = window_frame["total_return"] - window_frame["trade_count"] * roundtrip_cost_on_margin
+    window_frame.insert(0, "name", candidate.name)
+    window_frame.insert(0, "history_bars", len(candles))
+    window_frame.insert(0, "bar", bar)
+    window_frame.insert(0, "symbol", symbol)
+
+    monthly = summarize_periods(window_frame, "M", roundtrip_cost_on_margin)
+    quarterly = summarize_periods(window_frame, "Q", roundtrip_cost_on_margin)
+    full_result = candidate.run(candles=candles, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+    equity_monthly = summarize_equity_periods(full_result, "M")
+    equity_quarterly = summarize_equity_periods(full_result, "Q")
+    net_trade_equity_monthly = summarize_cost_adjusted_trade_equity_periods(full_result, "M", roundtrip_cost_on_margin)
+    net_trade_equity_quarterly = summarize_cost_adjusted_trade_equity_periods(full_result, "Q", roundtrip_cost_on_margin)
+    window_frame.to_csv("ultrashort-rsi2-window-distribution.csv", index=False)
+    monthly.to_csv("ultrashort-rsi2-monthly.csv", index=False)
+    quarterly.to_csv("ultrashort-rsi2-quarterly.csv", index=False)
+    equity_monthly.to_csv("ultrashort-rsi2-equity-monthly.csv", index=False)
+    equity_quarterly.to_csv("ultrashort-rsi2-equity-quarterly.csv", index=False)
+    net_trade_equity_monthly.to_csv("ultrashort-rsi2-net-trade-equity-monthly.csv", index=False)
+    net_trade_equity_quarterly.to_csv("ultrashort-rsi2-net-trade-equity-quarterly.csv", index=False)
+
+    summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin)
+    first_candle = _format_ts(candles[0].ts)
+    last_candle = _format_ts(candles[-1].ts)
+    print(f"actual data range UTC: {first_candle} -> {last_candle}; bars={len(candles)}")
+    print(
+        summary[
+            [
+                "sample_count",
+                "avg_return",
+                "ci95_low",
+                "avg_trades_per_window",
+                "roundtrip_cost_on_margin",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trades",
+                "trade_win_rate",
+                "avg_win_return",
+                "avg_loss_return_abs",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].to_string(index=False)
+    )
+    print("monthly")
+    print(monthly.to_string(index=False))
+    print("quarterly")
+    print(quarterly.to_string(index=False))
+    print("equity monthly")
+    print(equity_monthly.to_string(index=False))
+    print("equity quarterly")
+    print(equity_quarterly.to_string(index=False))
+    print("cost-adjusted closed-trade equity monthly")
+    print(net_trade_equity_monthly.to_string(index=False))
+    print("cost-adjusted closed-trade equity quarterly")
+    print(net_trade_equity_quarterly.to_string(index=False))
+    return 0
+
+
+def strategy_timeframe_analysis(
+    *,
+    symbols: tuple[str, ...],
+    bars: tuple[str, ...],
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+    period: str,
+) -> int:
+    client = OkxClient()
+    candidates = build_strategy_timeframe_candidates()
+    summary_rows: list[dict[str, object]] = []
+    period_frames: list[pd.DataFrame] = []
+    availability_rows: list[dict[str, object]] = []
+    for symbol in symbols:
+        for bar in bars:
+            requested_bars = history_bars_for_years(bar, years)
+            candles = get_candles_cached(client, symbol, bar, requested_bars)
+            first_ts = candles[0].ts
+            last_ts = candles[-1].ts
+            availability_rows.append(
+                {
+                    "symbol": symbol,
+                    "bar": bar,
+                    "requested_years": years,
+                    "requested_bars": requested_bars,
+                    "actual_bars": len(candles),
+                    "first_candle": _format_ts(first_ts),
+                    "last_candle": _format_ts(last_ts),
+                    "actual_days": (last_ts - first_ts) / 86_400_000,
+                    "complete_requested_range": len(candles) >= requested_bars,
+                }
+            )
+            for candidate in candidates:
+                rows = evaluate_candidate_window_rows(
+                    candidate=candidate,
+                    candles=candles,
+                    window_size=window_size,
+                    leverage=LEVERAGE,
+                )
+                summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin).iloc[0].to_dict()
+                summary_rows.append(
+                    {
+                        "symbol": symbol,
+                        "bar": bar,
+                        "requested_bars": requested_bars,
+                        "actual_bars": len(candles),
+                        "first_candle": _format_ts(first_ts),
+                        "last_candle": _format_ts(last_ts),
+                        **summary,
+                    }
+                )
+                window_frame = pd.DataFrame(rows).drop(columns=["trades"])
+                period_frame = summarize_periods(window_frame, period, roundtrip_cost_on_margin)
+                period_frame.insert(0, "name", candidate.name)
+                period_frame.insert(0, "bar", bar)
+                period_frame.insert(0, "symbol", symbol)
+                period_frames.append(period_frame)
+                print(f"done strategy timeframe {symbol} {bar} {candidate.name} windows={len(rows)}")
+    availability = pd.DataFrame(availability_rows)
+    summary_frame = sort_cost_results(pd.DataFrame(summary_rows))
+    period_summary = pd.concat(period_frames, ignore_index=True)
+    availability.to_csv("ultrashort-strategy-timeframe-availability.csv", index=False)
+    summary_frame.to_csv("ultrashort-strategy-timeframe-summary.csv", index=False)
+    period_summary.to_csv("ultrashort-strategy-timeframe-periods.csv", index=False)
+    print("availability")
+    print(availability.to_string(index=False))
+    print("summary")
+    print(
+        summary_frame[
+            [
+                "symbol",
+                "bar",
+                "name",
+                "actual_bars",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(50).to_string(index=False)
+    )
+    return 0
+
+
+def rsi2_variant_search(
+    *,
+    symbol: str,
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    requested_bars = history_bars_for_years(bar, years)
+    candles = get_candles_cached(client, symbol, bar, requested_bars)
+    candidates = [
+        build_rsi2_side_candidate(trend, long_threshold, short_threshold, exit_rsi, side_mode)
+        for trend in (50, 120, 240, 480)
+        for long_threshold, short_threshold in (
+            (2.0, 98.0),
+            (3.0, 97.0),
+            (5.0, 95.0),
+        )
+        for exit_rsi in (45.0, 55.0)
+        for side_mode in ("both", "long", "short")
+    ]
+    rows: list[dict[str, object]] = []
+    for candidate in candidates:
+        metrics = evaluate_candidate_all_windows(
+            candidate=candidate,
+            candles=candles,
+            window_size=window_size,
+            leverage=LEVERAGE,
+        )
+        rows.append(
+            {
+                "symbol": symbol,
+                "bar": bar,
+                "requested_bars": requested_bars,
+                "actual_bars": len(candles),
+                "first_candle": _format_ts(candles[0].ts),
+                "last_candle": _format_ts(candles[-1].ts),
+                **metrics,
+            }
+        )
+        print(f"done rsi2 variant {candidate.name}")
+    frame = sort_cost_results(add_cost_metrics(pd.DataFrame(rows), roundtrip_cost_on_margin))
+    frame.to_csv("ultrashort-rsi2-variant-search.csv", index=False)
+    print(
+        frame[
+            [
+                "symbol",
+                "bar",
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "avg_win_return",
+                "avg_loss_return_abs",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(50).to_string(index=False)
+    )
+    return 0
+
+
+def build_best_known_candidates() -> list[Candidate]:
+    bbmr_config = BBMRConfig(band_length=20, std_multiplier=2.5, bandwidth_lookback=50, stop_loss_pct=0.005)
+    return [
+        Candidate(
+            "bbmr-l20-m2.5-sl0.005",
+            50,
+            lambda candles, leverage, warmup_bars, config=bbmr_config: run_bbmr_segment(
+                candles=candles,
+                leverage=leverage,
+                warmup_bars=warmup_bars,
+                config=config,
+            ),
+        ),
+        build_rsi2_side_candidate(50, 3.0, 97.0, 45.0, "long"),
+        build_rsi2_side_candidate(50, 3.0, 97.0, 55.0, "long"),
+        build_rsi2_side_candidate(240, 2.0, 98.0, 55.0, "long"),
+        build_trend_rsi_bb_long_candidate(480, 20, 2.0, 5.0, 45.0, 0.005),
+        build_trend_rsi_bb_long_candidate(240, 20, 2.5, 5.0, 55.0, 0.008),
+        build_regime_hybrid_candidate(240, 240, 0.015, 2.0, 55.0, 2.5, 0.008),
+        build_regime_hybrid_candidate(50, 240, 0.02, 3.0, 55.0, 2.5, 0.005),
+    ]
+
+
+def best_total_annualized(
+    *,
+    symbols: tuple[str, ...],
+    bar: str,
+    years: float,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    rows: list[dict[str, object]] = []
+    horizon_rows: list[dict[str, object]] = []
+    horizons = (
+        ("3y", pd.DateOffset(years=3)),
+        ("1y", pd.DateOffset(years=1)),
+        ("6m", pd.DateOffset(months=6)),
+        ("3m", pd.DateOffset(months=3)),
+    )
+    for symbol in symbols:
+        candles = get_candles_cached(client, symbol, bar, history_bars_for_years(bar, years))
+        for candidate in build_best_known_candidates():
+            result = candidate.run(candles=candles, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+            net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+            metrics = annualized_metrics_from_equity(net_equity, candles[0].ts, candles[-1].ts)
+            gross_years = (candles[-1].ts - candles[0].ts) / 86_400_000 / 365
+            gross_annualized = (1.0 + result.total_return) ** (1.0 / gross_years) - 1.0 if result.total_return > -1.0 else 0.0
+            rows.append(
+                {
+                    "symbol": symbol,
+                    "bar": bar,
+                    "name": candidate.name,
+                    "first_candle": _format_ts(candles[0].ts),
+                    "last_candle": _format_ts(candles[-1].ts),
+                    "years": gross_years,
+                    "trades": result.trade_count,
+                    "gross_total_return": result.total_return,
+                    "gross_annualized_return": gross_annualized,
+                    "gross_max_drawdown_mark_to_market": result.max_drawdown,
+                    **metrics,
+                }
+            )
+            horizon_frame = recent_horizon_metrics_from_equity(net_equity, candles[-1].ts, horizons)
+            for horizon_row in horizon_frame.to_dict("records"):
+                horizon_rows.append(
+                    {
+                        "symbol": symbol,
+                        "bar": bar,
+                        "name": candidate.name,
+                        "first_candle": _format_ts(candles[0].ts),
+                        "last_candle": _format_ts(candles[-1].ts),
+                        "trades": result.trade_count,
+                        **horizon_row,
+                    }
+                )
+    frame = pd.DataFrame(rows).sort_values(["net_calmar", "net_annualized_return"], ascending=False)
+    horizon_output = pd.DataFrame(horizon_rows)
+    horizon_output["horizon"] = pd.Categorical(horizon_output["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
+    horizon_output = horizon_output.sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
+    frame.to_csv("ultrashort-best-total-annualized.csv", index=False)
+    horizon_output.to_csv("ultrashort-best-horizon-returns.csv", index=False)
+    print(frame.to_string(index=False))
+    print("recent horizon returns")
+    print(horizon_output.to_string(index=False))
+    return 0
+
+
+def ma_cross_search(
+    *,
+    symbols: tuple[str, ...],
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    rows: list[dict[str, object]] = []
+    for symbol in symbols:
+        candles = get_candles_cached(client, symbol, bar, history_bars_for_years(bar, years))
+        candidates = [
+            build_ma_cross_candidate(fast, slow, side_mode)
+            for fast, slow in ((12, 48), (20, 80), (30, 120), (50, 200), (80, 320))
+            for side_mode in ("both", "long", "short")
+        ]
+        for candidate in candidates:
+            metrics = evaluate_candidate_all_windows(
+                candidate=candidate,
+                candles=candles,
+                window_size=window_size,
+                leverage=LEVERAGE,
+            )
+            rows.append(
+                {
+                    "symbol": symbol,
+                    "bar": bar,
+                    "actual_bars": len(candles),
+                    "first_candle": _format_ts(candles[0].ts),
+                    "last_candle": _format_ts(candles[-1].ts),
+                    **metrics,
+                }
+            )
+            print(f"done ma cross {symbol} {candidate.name}")
+    frame = sort_cost_results(add_cost_metrics(pd.DataFrame(rows), roundtrip_cost_on_margin))
+    frame.to_csv("ultrashort-ma-cross-search.csv", index=False)
+    print(
+        frame[
+            [
+                "symbol",
+                "bar",
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(40).to_string(index=False)
+    )
+    return 0
+
+
+def trend_rsi_bb_search(
+    *,
+    symbols: tuple[str, ...],
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    rows: list[dict[str, object]] = []
+    candidates = [
+        build_trend_rsi_bb_long_candidate(trend_sma, band_length, std_multiplier, rsi_threshold, exit_rsi, stop_loss_pct)
+        for trend_sma in (120, 240, 480)
+        for band_length in (20, 30)
+        for std_multiplier in (2.0, 2.5)
+        for rsi_threshold in (2.0, 3.0, 5.0)
+        for exit_rsi in (45.0, 55.0)
+        for stop_loss_pct in (0.005, 0.008)
+    ]
+    for symbol in symbols:
+        candles = get_candles_cached(client, symbol, bar, history_bars_for_years(bar, years))
+        for candidate in candidates:
+            metrics = evaluate_candidate_all_windows(
+                candidate=candidate,
+                candles=candles,
+                window_size=window_size,
+                leverage=LEVERAGE,
+            )
+            rows.append(
+                {
+                    "symbol": symbol,
+                    "bar": bar,
+                    "actual_bars": len(candles),
+                    "first_candle": _format_ts(candles[0].ts),
+                    "last_candle": _format_ts(candles[-1].ts),
+                    **metrics,
+                }
+            )
+            print(f"done trend rsi bb {symbol} {candidate.name}")
+    frame = sort_cost_results(add_cost_metrics(pd.DataFrame(rows), roundtrip_cost_on_margin))
+    frame.to_csv("ultrashort-trend-rsi-bb-search.csv", index=False)
+    print(
+        frame[
+            [
+                "symbol",
+                "bar",
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "avg_win_return",
+                "avg_loss_return_abs",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(50).to_string(index=False)
+    )
+    return 0
+
+
+def regime_analysis(
+    *,
+    symbols: tuple[str, ...],
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    window_frames: list[pd.DataFrame] = []
+    for symbol in symbols:
+        candles = get_candles_cached(client, symbol, bar, history_bars_for_years(bar, years))
+        for candidate in build_best_known_candidates():
+            rows = evaluate_candidate_window_rows(
+                candidate=candidate,
+                candles=candles,
+                window_size=window_size,
+                leverage=LEVERAGE,
+            )
+            frame = add_market_regime_columns(candles, rows, roundtrip_cost_on_margin)
+            frame.insert(0, "name", candidate.name)
+            frame.insert(0, "last_candle", _format_ts(candles[-1].ts))
+            frame.insert(0, "first_candle", _format_ts(candles[0].ts))
+            frame.insert(0, "bar", bar)
+            frame.insert(0, "symbol", symbol)
+            window_frames.append(frame)
+            print(f"done regime {symbol} {candidate.name} windows={len(frame)}")
+    windows = pd.concat(window_frames, ignore_index=True)
+    summary = summarize_regime_columns(windows)
+    windows.to_csv("ultrashort-regime-windows.csv", index=False)
+    summary.to_csv("ultrashort-regime-summary.csv", index=False)
+    print(
+        summary[
+            [
+                "symbol",
+                "bar",
+                "name",
+                "regime_type",
+                "regime",
+                "sample_count",
+                "avg_net_return",
+                "median_net_return",
+                "positive_window_rate",
+                "avg_trades",
+                "avg_market_return",
+                "avg_realized_vol",
+                "avg_ma240_distance",
+            ]
+        ].to_string(index=False)
+    )
+    return 0
+
+
+def eth_btc_signal_search(
+    *,
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    requested_bars = history_bars_for_years(bar, years)
+    eth = get_candles_cached(client, "ETH-USDT-SWAP", bar, requested_bars)
+    btc = get_candles_cached(client, "BTC-USDT-SWAP", bar, requested_bars)
+    eth, btc = align_pair_candles(eth, btc)
+    candidates = [
+        build_eth_btc_rsi_filter_candidate(eth_trend, eth_rsi, eth_exit, btc_trend, btc_momentum, btc_min_momentum)
+        for eth_trend in (50, 120)
+        for eth_rsi in (2.0, 3.0, 5.0)
+        for eth_exit in (45.0, 55.0)
+        for btc_trend in (120, 240, 480)
+        for btc_momentum in (96, 240)
+        for btc_min_momentum in (0.0, 0.01)
+    ]
+    summary_rows: list[dict[str, object]] = []
+    total_rows: list[dict[str, object]] = []
+    horizon_rows: list[dict[str, object]] = []
+    horizons = (
+        ("3y", pd.DateOffset(years=3)),
+        ("1y", pd.DateOffset(years=1)),
+        ("6m", pd.DateOffset(months=6)),
+        ("3m", pd.DateOffset(months=3)),
+    )
+    for candidate in candidates:
+        rows = evaluate_pair_candidate_window_rows(
+            candidate=candidate,
+            eth_candles=eth,
+            btc_candles=btc,
+            window_size=window_size,
+            leverage=LEVERAGE,
+        )
+        summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin).iloc[0].to_dict()
+        summary_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "actual_bars": len(eth),
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                **summary,
+            }
+        )
+
+        result = candidate.run(eth_candles=eth, btc_candles=btc, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+        net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+        metrics = annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
+        years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
+        gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
+        total_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "name": candidate.name,
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                "years": years_actual,
+                "trades": result.trade_count,
+                "gross_total_return": result.total_return,
+                "gross_annualized_return": gross_annualized,
+                "gross_max_drawdown_mark_to_market": result.max_drawdown,
+                **metrics,
+            }
+        )
+        horizon_frame = recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, horizons)
+        for horizon_row in horizon_frame.to_dict("records"):
+            horizon_rows.append(
+                {
+                    "symbol": "ETH-USDT-SWAP",
+                    "signal_symbol": "BTC-USDT-SWAP",
+                    "bar": bar,
+                    "name": candidate.name,
+                    "trades": result.trade_count,
+                    **horizon_row,
+                }
+            )
+        print(f"done eth btc signal {candidate.name}")
+
+    summary = sort_cost_results(pd.DataFrame(summary_rows))
+    totals = pd.DataFrame(total_rows).sort_values(["net_calmar", "net_annualized_return"], ascending=False)
+    horizon = pd.DataFrame(horizon_rows)
+    horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
+    horizon = horizon.sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
+    summary.to_csv("ultrashort-eth-btc-signal-summary.csv", index=False)
+    totals.to_csv("ultrashort-eth-btc-signal-total.csv", index=False)
+    horizon.to_csv("ultrashort-eth-btc-signal-horizon.csv", index=False)
+    print("window summary")
+    print(
+        summary[
+            [
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(30).to_string(index=False)
+    )
+    print("total")
+    print(totals.head(30).to_string(index=False))
+    print("horizon")
+    print(horizon.head(60).to_string(index=False))
+    return 0
+
+
+def eth_btc_shock_filter_search(
+    *,
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    requested_bars = history_bars_for_years(bar, years)
+    eth = get_candles_cached(client, "ETH-USDT-SWAP", bar, requested_bars)
+    btc = get_candles_cached(client, "BTC-USDT-SWAP", bar, requested_bars)
+    eth, btc = align_pair_candles(eth, btc)
+    candidates = [
+        build_eth_btc_shock_filter_candidate(
+            eth_trend,
+            eth_rsi,
+            eth_exit,
+            btc_trend,
+            btc_momentum,
+            btc_min_momentum,
+            btc_shock_lookback,
+            btc_max_realized_vol,
+            btc_max_drawdown,
+        )
+        for eth_trend in (50,)
+        for eth_rsi in (3.0,)
+        for eth_exit in (45.0, 55.0)
+        for btc_trend in (480,)
+        for btc_momentum in (240,)
+        for btc_min_momentum in (0.0, 0.01)
+        for btc_shock_lookback in (96, 240)
+        for btc_max_realized_vol in (0.006, 0.01)
+        for btc_max_drawdown in (0.03, 0.05, 0.08)
+    ]
+    summary_rows: list[dict[str, object]] = []
+    total_rows: list[dict[str, object]] = []
+    horizon_rows: list[dict[str, object]] = []
+    horizons = (
+        ("3y", pd.DateOffset(years=3)),
+        ("1y", pd.DateOffset(years=1)),
+        ("6m", pd.DateOffset(months=6)),
+        ("3m", pd.DateOffset(months=3)),
+    )
+    for candidate in candidates:
+        rows = evaluate_pair_candidate_window_rows(
+            candidate=candidate,
+            eth_candles=eth,
+            btc_candles=btc,
+            window_size=window_size,
+            leverage=LEVERAGE,
+        )
+        summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin).iloc[0].to_dict()
+        summary_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "actual_bars": len(eth),
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                **summary,
+            }
+        )
+
+        result = candidate.run(eth_candles=eth, btc_candles=btc, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+        net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+        metrics = annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
+        years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
+        gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
+        total_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "name": candidate.name,
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                "years": years_actual,
+                "trades": result.trade_count,
+                "gross_total_return": result.total_return,
+                "gross_annualized_return": gross_annualized,
+                "gross_max_drawdown_mark_to_market": result.max_drawdown,
+                **metrics,
+            }
+        )
+        horizon_frame = recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, horizons)
+        for horizon_row in horizon_frame.to_dict("records"):
+            horizon_rows.append(
+                {
+                    "symbol": "ETH-USDT-SWAP",
+                    "signal_symbol": "BTC-USDT-SWAP",
+                    "bar": bar,
+                    "name": candidate.name,
+                    "trades": result.trade_count,
+                    **horizon_row,
+                }
+            )
+        print(f"done eth btc shock filter {candidate.name}")
+
+    summary = sort_cost_results(pd.DataFrame(summary_rows))
+    totals = pd.DataFrame(total_rows).sort_values(["net_calmar", "net_annualized_return"], ascending=False)
+    horizon = pd.DataFrame(horizon_rows)
+    horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
+    horizon = horizon.sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
+    summary.to_csv("ultrashort-eth-btc-shock-filter-summary.csv", index=False)
+    totals.to_csv("ultrashort-eth-btc-shock-filter-total.csv", index=False)
+    horizon.to_csv("ultrashort-eth-btc-shock-filter-horizon.csv", index=False)
+    print("window summary")
+    print(
+        summary[
+            [
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(30).to_string(index=False)
+    )
+    print("total")
+    print(totals.head(30).to_string(index=False))
+    print("horizon")
+    print(horizon.head(60).to_string(index=False))
+    return 0
+
+
+def eth_btc_ratio_search(
+    *,
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    requested_bars = history_bars_for_years(bar, years)
+    eth = get_candles_cached(client, "ETH-USDT-SWAP", bar, requested_bars)
+    btc = get_candles_cached(client, "BTC-USDT-SWAP", bar, requested_bars)
+    eth, btc = align_pair_candles(eth, btc)
+    candidates = [
+        build_eth_btc_ratio_pullback_candidate(btc_trend, btc_momentum, btc_min_momentum, ratio_length, ratio_std, ratio_rsi, stop)
+        for btc_trend in (480,)
+        for btc_momentum in (96, 240)
+        for btc_min_momentum in (0.0, 0.01)
+        for ratio_length in (48, 96)
+        for ratio_std in (1.5, 2.0)
+        for ratio_rsi in (5.0,)
+        for stop in (0.005, 0.008)
+    ]
+    summary_rows: list[dict[str, object]] = []
+    total_rows: list[dict[str, object]] = []
+    horizon_rows: list[dict[str, object]] = []
+    horizons = (
+        ("3y", pd.DateOffset(years=3)),
+        ("1y", pd.DateOffset(years=1)),
+        ("6m", pd.DateOffset(months=6)),
+        ("3m", pd.DateOffset(months=3)),
+    )
+    for candidate in candidates:
+        rows = evaluate_pair_candidate_window_rows(
+            candidate=candidate,
+            eth_candles=eth,
+            btc_candles=btc,
+            window_size=window_size,
+            leverage=LEVERAGE,
+        )
+        summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin).iloc[0].to_dict()
+        summary_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "actual_bars": len(eth),
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                **summary,
+            }
+        )
+        result = candidate.run(eth_candles=eth, btc_candles=btc, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+        net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+        metrics = annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
+        years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
+        gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
+        total_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "name": candidate.name,
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                "years": years_actual,
+                "trades": result.trade_count,
+                "gross_total_return": result.total_return,
+                "gross_annualized_return": gross_annualized,
+                "gross_max_drawdown_mark_to_market": result.max_drawdown,
+                **metrics,
+            }
+        )
+        horizon_frame = recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, horizons)
+        for horizon_row in horizon_frame.to_dict("records"):
+            horizon_rows.append(
+                {
+                    "symbol": "ETH-USDT-SWAP",
+                    "signal_symbol": "BTC-USDT-SWAP",
+                    "bar": bar,
+                    "name": candidate.name,
+                    "trades": result.trade_count,
+                    **horizon_row,
+                }
+            )
+        print(f"done eth btc ratio {candidate.name}")
+
+    summary = sort_cost_results(pd.DataFrame(summary_rows))
+    totals = pd.DataFrame(total_rows).sort_values(["net_calmar", "net_annualized_return"], ascending=False)
+    horizon = pd.DataFrame(horizon_rows)
+    horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
+    horizon = horizon.sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
+    summary.to_csv("ultrashort-eth-btc-ratio-summary.csv", index=False)
+    totals.to_csv("ultrashort-eth-btc-ratio-total.csv", index=False)
+    horizon.to_csv("ultrashort-eth-btc-ratio-horizon.csv", index=False)
+    print("window summary")
+    print(
+        summary[
+            [
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(30).to_string(index=False)
+    )
+    print("total")
+    print(totals.head(30).to_string(index=False))
+    print("horizon")
+    print(horizon.head(60).to_string(index=False))
+    return 0
+
+
+def btc_lead_eth_lag_search(
+    *,
+    bar: str,
+    years: float,
+    window_size: int,
+    roundtrip_cost_on_margin: float,
+) -> int:
+    client = OkxClient()
+    requested_bars = history_bars_for_years(bar, years)
+    eth = get_candles_cached(client, "ETH-USDT-SWAP", bar, requested_bars)
+    btc = get_candles_cached(client, "BTC-USDT-SWAP", bar, requested_bars)
+    eth, btc = align_pair_candles(eth, btc)
+    candidates = [
+        build_btc_lead_eth_lag_candidate(lead_lookback, btc_return_threshold, lag_gap, max_hold_bars, stop_loss_pct, take_profit_pct)
+        for lead_lookback in (8, 16)
+        for btc_return_threshold in (0.012, 0.018, 0.024)
+        for lag_gap in (0.006, 0.012)
+        for max_hold_bars in (8, 32)
+        for stop_loss_pct in (0.006, 0.008)
+        for take_profit_pct in (0.012, 0.018)
+    ]
+    summary_rows: list[dict[str, object]] = []
+    total_rows: list[dict[str, object]] = []
+    horizon_rows: list[dict[str, object]] = []
+    horizons = (
+        ("3y", pd.DateOffset(years=3)),
+        ("1y", pd.DateOffset(years=1)),
+        ("6m", pd.DateOffset(months=6)),
+        ("3m", pd.DateOffset(months=3)),
+    )
+    for candidate in candidates:
+        rows = evaluate_pair_candidate_window_rows(
+            candidate=candidate,
+            eth_candles=eth,
+            btc_candles=btc,
+            window_size=window_size,
+            leverage=LEVERAGE,
+        )
+        summary = add_cost_metrics(pd.DataFrame([summarize_window_rows(rows, candidate.name)]), roundtrip_cost_on_margin).iloc[0].to_dict()
+        summary_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "actual_bars": len(eth),
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                **summary,
+            }
+        )
+
+        result = candidate.run(eth_candles=eth, btc_candles=btc, leverage=LEVERAGE, warmup_bars=candidate.warmup_bars)
+        net_equity = cost_adjusted_trade_equity_frame(result, roundtrip_cost_on_margin)
+        metrics = annualized_metrics_from_equity(net_equity, eth[0].ts, eth[-1].ts)
+        years_actual = (eth[-1].ts - eth[0].ts) / 86_400_000 / 365
+        gross_annualized = (1.0 + result.total_return) ** (1.0 / years_actual) - 1.0 if result.total_return > -1.0 else 0.0
+        total_rows.append(
+            {
+                "symbol": "ETH-USDT-SWAP",
+                "signal_symbol": "BTC-USDT-SWAP",
+                "bar": bar,
+                "name": candidate.name,
+                "first_candle": _format_ts(eth[0].ts),
+                "last_candle": _format_ts(eth[-1].ts),
+                "years": years_actual,
+                "trades": result.trade_count,
+                "gross_total_return": result.total_return,
+                "gross_annualized_return": gross_annualized,
+                "gross_max_drawdown_mark_to_market": result.max_drawdown,
+                **metrics,
+            }
+        )
+        horizon_frame = recent_horizon_metrics_from_equity(net_equity, eth[-1].ts, horizons)
+        for horizon_row in horizon_frame.to_dict("records"):
+            horizon_rows.append(
+                {
+                    "symbol": "ETH-USDT-SWAP",
+                    "signal_symbol": "BTC-USDT-SWAP",
+                    "bar": bar,
+                    "name": candidate.name,
+                    "trades": result.trade_count,
+                    **horizon_row,
+                }
+            )
+        print(f"done btc lead eth lag {candidate.name}")
+
+    summary = sort_cost_results(pd.DataFrame(summary_rows))
+    totals = pd.DataFrame(total_rows).sort_values(["net_calmar", "net_annualized_return"], ascending=False)
+    horizon = pd.DataFrame(horizon_rows)
+    horizon["horizon"] = pd.Categorical(horizon["horizon"], categories=["3y", "1y", "6m", "3m"], ordered=True)
+    horizon = horizon.sort_values(["horizon", "net_annualized_return"], ascending=[True, False])
+    output_prefix = f"ultrashort-btc-lead-eth-lag-{bar}"
+    summary.to_csv(f"{output_prefix}-summary.csv", index=False)
+    totals.to_csv(f"{output_prefix}-total.csv", index=False)
+    horizon.to_csv(f"{output_prefix}-horizon.csv", index=False)
+    print("window summary")
+    print(
+        summary[
+            [
+                "name",
+                "sample_count",
+                "trades",
+                "net_avg_return",
+                "net_ci95_low",
+                "positive_window_rate",
+                "trade_win_rate",
+                "payoff_ratio",
+                "profit_factor",
+                "max_drawdown",
+            ]
+        ].head(30).to_string(index=False)
+    )
+    print("total")
+    print(totals.head(30).to_string(index=False))
+    print("horizon")
+    print(horizon.head(60).to_string(index=False))
+    return 0
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--focus-vwap", action="store_true")
+    parser.add_argument("--robust-vwap", action="store_true")
+    parser.add_argument("--robust-all", action="store_true")
+    parser.add_argument("--rsi2-cost-search", action="store_true")
+    parser.add_argument("--rsi2-period-analysis", action="store_true")
+    parser.add_argument("--strategy-timeframe-analysis", action="store_true")
+    parser.add_argument("--rsi2-variant-search", action="store_true")
+    parser.add_argument("--best-total-annualized", action="store_true")
+    parser.add_argument("--ma-cross-search", action="store_true")
+    parser.add_argument("--trend-rsi-bb-search", action="store_true")
+    parser.add_argument("--regime-analysis", action="store_true")
+    parser.add_argument("--eth-btc-signal-search", action="store_true")
+    parser.add_argument("--eth-btc-shock-filter-search", action="store_true")
+    parser.add_argument("--eth-btc-ratio-search", action="store_true")
+    parser.add_argument("--btc-lead-eth-lag-search", action="store_true")
+    parser.add_argument("--history-limit", type=int, default=ROBUST_HISTORY_LIMIT)
+    parser.add_argument("--window-size", type=int, default=WINDOW_SIZE)
+    parser.add_argument("--roundtrip-cost-on-margin", type=float, default=0.0012)
+    parser.add_argument("--symbol", default="BTC-USDT-SWAP")
+    parser.add_argument("--symbols", default="BTC-USDT-SWAP")
+    parser.add_argument("--bar", default="15m")
+    parser.add_argument("--bars", default=",".join(ANALYSIS_BARS))
+    parser.add_argument("--years", type=float, default=10.0)
+    parser.add_argument("--period", default="Q")
+    parser.add_argument("--rsi2-trend", type=int, default=50)
+    parser.add_argument("--rsi2-long-threshold", type=float, default=3.0)
+    parser.add_argument("--rsi2-short-threshold", type=float, default=97.0)
+    args = parser.parse_args()
+    if args.rsi2_period_analysis:
+        raise SystemExit(
+            rsi2_period_analysis(
+                symbol=args.symbol,
+                bar=args.bar,
+                history_limit=args.history_limit,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+                trend=args.rsi2_trend,
+                long_threshold=args.rsi2_long_threshold,
+                short_threshold=args.rsi2_short_threshold,
+            )
+        )
+    if args.rsi2_cost_search:
+        raise SystemExit(robust_rsi2_cost_search(args.history_limit, args.window_size, args.roundtrip_cost_on_margin))
+    if args.best_total_annualized:
+        raise SystemExit(
+            best_total_annualized(
+                symbols=tuple(value.strip() for value in args.symbols.split(",") if value.strip()),
+                bar=args.bar,
+                years=args.years,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.ma_cross_search:
+        raise SystemExit(
+            ma_cross_search(
+                symbols=tuple(value.strip() for value in args.symbols.split(",") if value.strip()),
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.trend_rsi_bb_search:
+        raise SystemExit(
+            trend_rsi_bb_search(
+                symbols=tuple(value.strip() for value in args.symbols.split(",") if value.strip()),
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.regime_analysis:
+        raise SystemExit(
+            regime_analysis(
+                symbols=tuple(value.strip() for value in args.symbols.split(",") if value.strip()),
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.eth_btc_signal_search:
+        raise SystemExit(
+            eth_btc_signal_search(
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.eth_btc_shock_filter_search:
+        raise SystemExit(
+            eth_btc_shock_filter_search(
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.eth_btc_ratio_search:
+        raise SystemExit(
+            eth_btc_ratio_search(
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.btc_lead_eth_lag_search:
+        raise SystemExit(
+            btc_lead_eth_lag_search(
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.rsi2_variant_search:
+        raise SystemExit(
+            rsi2_variant_search(
+                symbol=args.symbol,
+                bar=args.bar,
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+            )
+        )
+    if args.strategy_timeframe_analysis:
+        raise SystemExit(
+            strategy_timeframe_analysis(
+                symbols=tuple(value.strip() for value in args.symbols.split(",") if value.strip()),
+                bars=tuple(value.strip() for value in args.bars.split(",") if value.strip()),
+                years=args.years,
+                window_size=args.window_size,
+                roundtrip_cost_on_margin=args.roundtrip_cost_on_margin,
+                period=args.period,
+            )
+        )
+    if args.robust_all:
+        raise SystemExit(robust_all(args.history_limit, args.window_size))
+    if args.robust_vwap:
+        raise SystemExit(robust_vwap(args.history_limit, args.window_size))
+    raise SystemExit(focus_vwap() if args.focus_vwap else main())

+ 452 - 0
scripts/generate_ultrashort_report.py

@@ -0,0 +1,452 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+from dataclasses import dataclass
+from html import escape
+from pathlib import Path
+
+import pandas as pd
+
+ROUNDTRIP_COST_ON_MARGIN = 0.0012
+REPORT_FILE = Path("ultrashort-recent-report.html")
+REPORT_DIR = Path("reports/ultrashort")
+HORIZONS = (
+    ("3y", pd.DateOffset(years=3)),
+    ("1y", pd.DateOffset(years=1)),
+    ("6m", pd.DateOffset(months=6)),
+    ("3m", pd.DateOffset(months=3)),
+)
+
+
+@dataclass(frozen=True)
+class StrategySpec:
+    label: str
+    symbol: str
+    bar: str
+    candidate: object
+    is_pair: bool = False
+
+
+def load_explore_module():
+    path = Path(__file__).resolve().with_name("explore_ultrashort.py")
+    spec = importlib.util.spec_from_file_location("explore_ultrashort", path)
+    if spec is None or spec.loader is None:
+        raise RuntimeError("cannot load explore_ultrashort.py")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def load_cached_history(explore, symbol: str, bar: str, requested_bars: int):
+    candles, _ = explore.load_cached_candles(explore.CANDLE_CACHE_DIR, symbol, bar)
+    if not candles:
+        raise RuntimeError(f"missing cached candles: {symbol} {bar}")
+    return candles[-requested_bars:] if len(candles) > requested_bars else candles
+
+
+def pct(value: float) -> str:
+    return f"{value * 100:.2f}%"
+
+
+def money(value: float) -> str:
+    return f"{value:,.0f}"
+
+
+def normalize_equity(frame: pd.DataFrame, cutoff: pd.Timestamp) -> pd.DataFrame:
+    recent = frame[frame["ts"] >= cutoff][["ts", "equity"]].copy()
+    if recent.empty:
+        recent = frame[["ts", "equity"]].copy()
+    recent["equity"] = recent["equity"] / float(recent["equity"].iloc[0]) * 10_000.0
+    return recent
+
+
+def monthly_from_equity(frame: pd.DataFrame) -> pd.DataFrame:
+    month_end = frame.set_index("ts")["equity"].resample("ME").last().ffill()
+    month_start = month_end.shift(1)
+    if len(month_end):
+        month_start.iloc[0] = float(frame["equity"].iloc[0])
+    monthly = pd.DataFrame(
+        {
+            "period": month_end.index.tz_localize(None).to_period("M").astype(str),
+            "return": month_end.to_numpy() / month_start.to_numpy() - 1.0,
+            "end_equity": month_end.to_numpy(),
+        }
+    )
+    return monthly
+
+
+def combine_equities(frames: list[pd.DataFrame]) -> pd.DataFrame:
+    combined = pd.concat(
+        [
+            frame.set_index("ts")["equity"].rename(str(index))
+            for index, frame in enumerate(frames)
+        ],
+        axis=1,
+        sort=True,
+    ).sort_index().ffill().dropna()
+    return pd.DataFrame({"ts": combined.index, "equity": combined.sum(axis=1).to_numpy()})
+
+
+def daily_return_frame(equities: dict[str, pd.DataFrame]) -> pd.DataFrame:
+    combined = pd.concat(
+        [
+            frame.set_index("ts")["equity"].rename(name)
+            for name, frame in equities.items()
+        ],
+        axis=1,
+        sort=True,
+    ).sort_index().ffill().dropna()
+    return combined.pct_change().dropna()
+
+
+def yearly_from_equity(label: str, frame: pd.DataFrame) -> list[dict[str, object]]:
+    year_end = frame.set_index("ts")["equity"].resample("YE").last().ffill()
+    year_start = year_end.shift(1)
+    if len(year_end):
+        year_start.iloc[0] = float(frame["equity"].iloc[0])
+    return [
+        {
+            "name": label,
+            "year": str(index.year),
+            "return": pct(float(end / start - 1.0)),
+            "end_equity": money(float(end)),
+        }
+        for index, start, end in zip(year_end.index, year_start.to_numpy(), year_end.to_numpy())
+    ]
+
+
+def horizon_rows(explore, label: str, kind: str, frame: pd.DataFrame) -> list[dict[str, object]]:
+    last_ts = int(frame["ts"].iloc[-1].timestamp() * 1000)
+    horizons = explore.recent_horizon_metrics_from_equity(frame, last_ts, HORIZONS)
+    return [
+        {
+            kind: label,
+            "horizon": row["horizon"],
+            "return": pct(float(row["net_total_return"])),
+            "annualized": pct(float(row["net_annualized_return"])),
+            "max_dd": pct(float(row["net_max_drawdown"])),
+            "calmar": f"{float(row['net_calmar']):.2f}",
+        }
+        for row in horizons.to_dict("records")
+    ]
+
+
+def make_svg(curves: list[dict[str, object]]) -> str:
+    width = 1200
+    height = 460
+    left = 58
+    top = 28
+    right = 24
+    bottom = 44
+    plot_width = width - left - right
+    plot_height = height - top - bottom
+    all_points = [point for curve in curves for point in curve["points"]]
+    min_ts = min(ts for ts, _ in all_points)
+    max_ts = max(ts for ts, _ in all_points)
+    min_value = min(value for _, value in all_points)
+    max_value = max(value for _, value in all_points)
+    if max_value == min_value:
+        max_value += 1.0
+
+    def x(ts: float) -> float:
+        return left + (ts - min_ts) / (max_ts - min_ts) * plot_width
+
+    def y(value: float) -> float:
+        return top + (max_value - value) / (max_value - min_value) * plot_height
+
+    grid = []
+    for i in range(5):
+        yy = top + i * plot_height / 4
+        value = max_value - i * (max_value - min_value) / 4
+        grid.append(f'<line x1="{left}" y1="{yy:.1f}" x2="{width-right}" y2="{yy:.1f}" class="grid"/>')
+        grid.append(f'<text x="8" y="{yy + 4:.1f}" class="axis">{money(value)}</text>')
+
+    paths = []
+    for curve in curves:
+        points = " ".join(f"{x(ts):.1f},{y(value):.1f}" for ts, value in curve["points"])
+        paths.append(f'<polyline points="{points}" fill="none" stroke="{curve["color"]}" stroke-width="2.4"/>')
+
+    legend = []
+    legend_x = left
+    legend_y = height - 18
+    for curve in curves:
+        legend.append(f'<circle cx="{legend_x}" cy="{legend_y}" r="5" fill="{curve["color"]}"/>')
+        legend.append(f'<text x="{legend_x + 10}" y="{legend_y + 4}" class="legend">{escape(str(curve["label"]))}</text>')
+        legend_x += 170
+
+    return f"""
+    <svg viewBox="0 0 {width} {height}" role="img" aria-label="Recent equity curves">
+      <style>
+        .grid {{ stroke: #e5e7eb; stroke-width: 1; }}
+        .axis {{ fill: #6b7280; font: 12px Inter, system-ui, sans-serif; }}
+        .legend {{ fill: #111827; font: 12px Inter, system-ui, sans-serif; }}
+      </style>
+      <rect x="0" y="0" width="{width}" height="{height}" fill="#ffffff"/>
+      {''.join(grid)}
+      {''.join(paths)}
+      {''.join(legend)}
+    </svg>
+    """
+
+
+def render_table(frame: pd.DataFrame, columns: list[str]) -> str:
+    header = "".join(f"<th>{escape(column)}</th>" for column in columns)
+    rows = []
+    for row in frame.to_dict("records"):
+        cells = "".join(f"<td>{escape(str(row[column]))}</td>" for column in columns)
+        rows.append(f"<tr>{cells}</tr>")
+    return f"<table><thead><tr>{header}</tr></thead><tbody>{''.join(rows)}</tbody></table>"
+
+
+def main() -> int:
+    explore = load_explore_module()
+    specs = [
+        StrategySpec("BTC RSI2 Guarded 15m", "BTC-USDT-SWAP", "15m", explore.build_rsi2_long_guarded_candidate(240, 2.0, 55.0, 0.008, 48)),
+        StrategySpec("BTC Trend RSI-BB 15m", "BTC-USDT-SWAP", "15m", explore.build_trend_rsi_bb_long_candidate(240, 20, 2.5, 5.0, 55.0, 0.008)),
+        StrategySpec("ETH RSI2 15m", "ETH-USDT-SWAP", "15m", explore.build_rsi2_side_candidate(50, 3.0, 97.0, 55.0, "long")),
+        StrategySpec("ETH Trend RSI-BB 15m", "ETH-USDT-SWAP", "15m", explore.build_trend_rsi_bb_long_candidate(480, 20, 2.0, 5.0, 45.0, 0.005)),
+        StrategySpec("ETH/BTC RSI Filter 15m", "ETH-USDT-SWAP", "15m", explore.build_eth_btc_rsi_filter_candidate(50, 3.0, 55.0, 480, 240, 0.0), True),
+        StrategySpec("BTC Lead ETH Lag 15m", "ETH-USDT-SWAP", "15m", explore.build_btc_lead_eth_lag_candidate(16, 0.024, 0.006, 32, 0.008, 0.018), True),
+        StrategySpec("BTC Lead ETH Lag 5m", "ETH-USDT-SWAP", "5m", explore.build_btc_lead_eth_lag_candidate(16, 0.018, 0.006, 32, 0.006, 0.018), True),
+        StrategySpec("BTC Lead ETH Lag 3m", "ETH-USDT-SWAP", "3m", explore.build_btc_lead_eth_lag_candidate(8, 0.012, 0.006, 32, 0.006, 0.012), True),
+    ]
+    colors = ["#2563eb", "#dc2626", "#059669", "#7c3aed", "#ea580c", "#0891b2", "#be123c", "#4b5563"]
+    result_rows: list[dict[str, object]] = []
+    monthly_rows: list[dict[str, object]] = []
+    monthly_raw_rows: list[dict[str, object]] = []
+    curves: list[dict[str, object]] = []
+    recent_equities: dict[str, pd.DataFrame] = {}
+    yearly_rows: list[dict[str, object]] = []
+    strategy_horizon_rows: list[dict[str, object]] = []
+
+    for index, spec in enumerate(specs):
+        requested_bars = explore.history_bars_for_years(spec.bar, 10.0)
+        candles = load_cached_history(explore, spec.symbol, spec.bar, requested_bars)
+        if spec.is_pair:
+            btc = load_cached_history(explore, "BTC-USDT-SWAP", spec.bar, requested_bars)
+            candles, btc = explore.align_pair_candles(candles, btc)
+            result = spec.candidate.run(eth_candles=candles, btc_candles=btc, leverage=explore.LEVERAGE, warmup_bars=spec.candidate.warmup_bars)
+        else:
+            result = spec.candidate.run(candles=candles, leverage=explore.LEVERAGE, warmup_bars=spec.candidate.warmup_bars)
+
+        equity = explore.cost_adjusted_trade_equity_frame(result, ROUNDTRIP_COST_ON_MARGIN)
+        strategy_horizon_rows.extend(horizon_rows(explore, spec.label, "strategy", equity))
+        cutoff = pd.to_datetime(candles[-1].ts, unit="ms", utc=True) - pd.DateOffset(years=3)
+        recent_equity = normalize_equity(equity, cutoff)
+        metrics = explore.annualized_metrics_from_equity(recent_equity, int(recent_equity["ts"].iloc[0].timestamp() * 1000), candles[-1].ts)
+        result_rows.append(
+            {
+                "strategy": spec.label,
+                "bar": spec.bar,
+                "trades": result.trade_count,
+                "3y_return": pct(metrics["net_total_return"]),
+                "3y_annualized": pct(metrics["net_annualized_return"]),
+                "3y_max_dd": pct(metrics["net_max_drawdown"]),
+                "3y_calmar": f"{metrics['net_calmar']:.2f}",
+            }
+        )
+        monthly = monthly_from_equity(equity)
+        monthly = monthly[monthly["period"].str.startswith(("2025-", "2026-"))].copy()
+        monthly.insert(0, "strategy", spec.label)
+        for row in monthly.to_dict("records"):
+            monthly_raw_rows.append(
+                {
+                    "strategy": row["strategy"],
+                    "period": row["period"],
+                    "return": float(row["return"]),
+                    "end_equity": float(row["end_equity"]),
+                }
+            )
+            monthly_rows.append(
+                {
+                    "strategy": row["strategy"],
+                    "period": row["period"],
+                    "return": pct(float(row["return"])),
+                    "end_equity": money(float(row["end_equity"])),
+                }
+            )
+        daily = recent_equity.set_index("ts")["equity"].resample("1D").last().ffill().reset_index()
+        recent_equities[spec.label] = daily
+        yearly_rows.extend(yearly_from_equity(spec.label, daily))
+        curves.append(
+            {
+                "label": spec.label,
+                "color": colors[index],
+                "points": [(float(row.ts.timestamp()), float(row.equity)) for row in daily.itertuples(index=False)],
+            }
+        )
+        print(f"done {spec.label}")
+
+    portfolio_specs = [
+        (
+            "Balanced 4",
+            [
+                "BTC RSI2 Guarded 15m",
+                "BTC Trend RSI-BB 15m",
+                "ETH/BTC RSI Filter 15m",
+                "BTC Lead ETH Lag 5m",
+            ],
+        ),
+        (
+            "Aggressive 5",
+            [
+                "BTC RSI2 Guarded 15m",
+                "ETH RSI2 15m",
+                "ETH/BTC RSI Filter 15m",
+                "BTC Lead ETH Lag 15m",
+                "BTC Lead ETH Lag 5m",
+            ],
+        ),
+        (
+            "Lead Lag Basket",
+            [
+                "BTC Lead ETH Lag 15m",
+                "BTC Lead ETH Lag 5m",
+                "BTC Lead ETH Lag 3m",
+            ],
+        ),
+    ]
+    portfolio_rows: list[dict[str, object]] = []
+    portfolio_monthly_rows: list[dict[str, object]] = []
+    portfolio_monthly_raw_rows: list[dict[str, object]] = []
+    portfolio_curves: list[dict[str, object]] = []
+    portfolio_equities: dict[str, pd.DataFrame] = {}
+    portfolio_horizon_rows: list[dict[str, object]] = []
+    portfolio_colors = ["#111827", "#b45309", "#0f766e"]
+    for index, (portfolio_name, names) in enumerate(portfolio_specs):
+        portfolio_equity = combine_equities([recent_equities[name] for name in names])
+        metrics = explore.annualized_metrics_from_equity(
+            portfolio_equity,
+            int(portfolio_equity["ts"].iloc[0].timestamp() * 1000),
+            int(portfolio_equity["ts"].iloc[-1].timestamp() * 1000),
+        )
+        portfolio_rows.append(
+            {
+                "portfolio": portfolio_name,
+                "legs": len(names),
+                "3y_return": pct(metrics["net_total_return"]),
+                "3y_annualized": pct(metrics["net_annualized_return"]),
+                "3y_max_dd": pct(metrics["net_max_drawdown"]),
+                "3y_calmar": f"{metrics['net_calmar']:.2f}",
+            }
+        )
+        portfolio_horizon_rows.extend(horizon_rows(explore, portfolio_name, "portfolio", portfolio_equity))
+        monthly = monthly_from_equity(portfolio_equity)
+        monthly = monthly[monthly["period"].str.startswith(("2025-", "2026-"))].copy()
+        monthly.insert(0, "portfolio", portfolio_name)
+        for row in monthly.to_dict("records"):
+            portfolio_monthly_raw_rows.append(
+                {
+                    "portfolio": row["portfolio"],
+                    "period": row["period"],
+                    "return": float(row["return"]),
+                    "end_equity": float(row["end_equity"]),
+                }
+            )
+            portfolio_monthly_rows.append(
+                {
+                    "portfolio": row["portfolio"],
+                    "period": row["period"],
+                    "return": pct(float(row["return"])),
+                    "end_equity": money(float(row["end_equity"])),
+                }
+            )
+        portfolio_equities[portfolio_name] = portfolio_equity
+        yearly_rows.extend(yearly_from_equity(portfolio_name, portfolio_equity))
+        portfolio_curves.append(
+            {
+                "label": portfolio_name,
+                "color": portfolio_colors[index],
+                "points": [(float(row.ts.timestamp()), float(row.equity)) for row in portfolio_equity.itertuples(index=False)],
+            }
+        )
+
+    summary = pd.DataFrame(result_rows)
+    monthly_frame = pd.DataFrame(monthly_rows)
+    monthly_raw = pd.DataFrame(monthly_raw_rows)
+    portfolio_summary = pd.DataFrame(portfolio_rows)
+    portfolio_monthly = pd.DataFrame(portfolio_monthly_rows)
+    strategy_horizon = pd.DataFrame(strategy_horizon_rows)
+    portfolio_horizon = pd.DataFrame(portfolio_horizon_rows)
+    portfolio_monthly_raw = pd.DataFrame(portfolio_monthly_raw_rows)
+    correlations = daily_return_frame({**recent_equities, **portfolio_equities}).corr()
+    correlation_rows = correlations.reset_index().rename(columns={"index": "name"})
+    worst_strategy_months = monthly_raw.sort_values("return").head(20).copy()
+    worst_strategy_months["return"] = worst_strategy_months["return"].map(pct)
+    worst_strategy_months["end_equity"] = worst_strategy_months["end_equity"].map(money)
+    worst_portfolio_months = portfolio_monthly_raw.sort_values("return").head(12).copy()
+    worst_portfolio_months["return"] = worst_portfolio_months["return"].map(pct)
+    worst_portfolio_months["end_equity"] = worst_portfolio_months["end_equity"].map(money)
+    yearly_frame = pd.DataFrame(yearly_rows)
+    REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    monthly_frame.to_csv(REPORT_DIR / "ultrashort-recent-monthly.csv", index=False)
+    summary.to_csv(REPORT_DIR / "ultrashort-recent-summary.csv", index=False)
+    portfolio_summary.to_csv(REPORT_DIR / "ultrashort-portfolio-summary.csv", index=False)
+    portfolio_monthly.to_csv(REPORT_DIR / "ultrashort-portfolio-monthly.csv", index=False)
+    strategy_horizon.to_csv(REPORT_DIR / "ultrashort-strategy-horizons.csv", index=False)
+    portfolio_horizon.to_csv(REPORT_DIR / "ultrashort-portfolio-horizons.csv", index=False)
+    correlation_rows.to_csv(REPORT_DIR / "ultrashort-correlation.csv", index=False)
+    worst_strategy_months.to_csv(REPORT_DIR / "ultrashort-worst-strategy-months.csv", index=False)
+    worst_portfolio_months.to_csv(REPORT_DIR / "ultrashort-worst-portfolio-months.csv", index=False)
+    yearly_frame.to_csv(REPORT_DIR / "ultrashort-yearly.csv", index=False)
+    html = f"""<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Ultra Short Recent Strategy Report</title>
+  <style>
+    body {{ margin: 0; background: #f8fafc; color: #111827; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
+    main {{ max-width: 1280px; margin: 0 auto; padding: 28px 24px 48px; }}
+    h1 {{ margin: 0 0 8px; font-size: 30px; }}
+    h2 {{ margin: 28px 0 12px; font-size: 20px; }}
+    .meta {{ color: #4b5563; margin-bottom: 18px; }}
+    .panel {{ background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 18px; overflow-x: auto; }}
+    table {{ width: 100%; border-collapse: collapse; font-size: 13px; white-space: nowrap; }}
+    th, td {{ text-align: right; padding: 8px 10px; border-bottom: 1px solid #e5e7eb; }}
+    th:first-child, td:first-child {{ text-align: left; }}
+    th {{ color: #374151; background: #f9fafb; font-weight: 700; }}
+    .note {{ color: #6b7280; font-size: 13px; line-height: 1.6; }}
+  </style>
+</head>
+<body>
+<main>
+  <h1>近 3 年超短线策略报告</h1>
+  <div class="meta">数据截至 {escape(str(pd.Timestamp.now("UTC").strftime("%Y-%m-%d %H:%M UTC")))},成本按每次完整交易扣除保证金 0.12%。曲线以近 3 年起点统一归一为 10,000。</div>
+  <section class="panel">{make_svg(curves)}</section>
+  <h2>近 3 年汇总</h2>
+  <section class="panel">{render_table(summary, ["strategy", "bar", "trades", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
+  <h2>单策略分周期表现</h2>
+  <section class="panel">{render_table(strategy_horizon, ["strategy", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
+  <h2>组合曲线</h2>
+  <section class="panel">{make_svg(portfolio_curves)}</section>
+  <h2>组合近 3 年汇总</h2>
+  <section class="panel">{render_table(portfolio_summary, ["portfolio", "legs", "3y_return", "3y_annualized", "3y_max_dd", "3y_calmar"])}</section>
+  <h2>组合分周期表现</h2>
+  <section class="panel">{render_table(portfolio_horizon, ["portfolio", "horizon", "return", "annualized", "max_dd", "calmar"])}</section>
+  <h2>组合 2025-2026 月度收益</h2>
+  <section class="panel">{render_table(portfolio_monthly, ["portfolio", "period", "return", "end_equity"])}</section>
+  <h2>年度收益</h2>
+  <section class="panel">{render_table(yearly_frame, ["name", "year", "return", "end_equity"])}</section>
+  <h2>最差月份</h2>
+  <section class="panel">{render_table(worst_portfolio_months, ["portfolio", "period", "return", "end_equity"])}</section>
+  <section class="panel">{render_table(worst_strategy_months, ["strategy", "period", "return", "end_equity"])}</section>
+  <h2>相关性矩阵</h2>
+  <section class="panel">{correlation_rows.round(3).to_html(index=False, escape=True)}</section>
+  <h2>2025-2026 月度收益</h2>
+  <section class="panel">{render_table(monthly_frame, ["strategy", "period", "return", "end_equity"])}</section>
+  <p class="note">组合按近 3 年起点等资金分配,不做月度再平衡。月度收益按成本调整后的闭合交易权益计算;若月内无平仓交易,收益可能显示为 0。</p>
+</main>
+</body>
+</html>
+"""
+    output_file = REPORT_DIR / REPORT_FILE
+    output_file.write_text(html, encoding="utf-8")
+    print(output_file)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 459 - 0
tests/test_explore_ultrashort.py

@@ -0,0 +1,459 @@
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+from okx_codex_trader.models import Candle
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+def load_explore_module():
+    path = Path(__file__).resolve().parents[1] / "scripts" / "explore_ultrashort.py"
+    spec = importlib.util.spec_from_file_location("explore_ultrashort", path)
+    assert spec is not None
+    module = importlib.util.module_from_spec(spec)
+    assert spec.loader is not None
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def build_candles(count: int) -> list[Candle]:
+    return [
+        Candle(
+            symbol="BTC-USDT-SWAP",
+            ts=index * 60_000,
+            open=100.0 + index,
+            high=101.0 + index,
+            low=99.0 + index,
+            close=100.0 + index,
+            volume=1_000.0 + index,
+        )
+        for index in range(count)
+    ]
+
+
+def build_result(
+    total_return: float,
+    trade_count: int,
+    win_rate: float,
+    max_drawdown: float,
+    trade_returns: list[float] | None = None,
+    equity_curve: list[dict[str, float | int]] | None = None,
+) -> SegmentResult:
+    return SegmentResult(
+        trade_count=trade_count,
+        total_return=total_return,
+        win_rate=win_rate,
+        max_drawdown=max_drawdown,
+        trades=[
+            {
+                "side": "Long",
+                "entry_time": "2026-04-01 00:00",
+                "exit_time": "2026-04-01 00:15",
+                "entry_price": 100.0,
+                "exit_price": 100.0 * (1.0 + trade_return),
+                "pnl": trade_return * 10_000.0,
+                "return_pct": trade_return * 100.0,
+            }
+            for trade_return in (trade_returns or [])
+        ],
+        open_position=None,
+        candles=[],
+        equity_curve=equity_curve or [],
+        entries=[],
+        exits=[],
+    )
+
+
+def test_evaluate_candidate_all_windows_uses_complete_non_overlapping_windows():
+    module = load_explore_module()
+    returns = iter([0.01, -0.02, 0.03])
+    calls: list[dict[str, object]] = []
+
+    def run_segment(*, candles, leverage, warmup_bars):
+        calls.append(
+            {
+                "first_ts": candles[0].ts,
+                "count": len(candles),
+                "leverage": leverage,
+                "warmup_bars": warmup_bars,
+            }
+        )
+        return build_result(
+            next(returns),
+            trade_count=2,
+            win_rate=0.5,
+            max_drawdown=0.04,
+            trade_returns=[0.02, -0.01],
+        )
+
+    metrics = module.evaluate_candidate_all_windows(
+        candidate=module.Candidate("test-candidate", warmup_bars=2, run=run_segment),
+        candles=build_candles(17),
+        window_size=5,
+        leverage=3,
+    )
+
+    assert calls == [
+        {"first_ts": 0, "count": 7, "leverage": 3, "warmup_bars": 2},
+        {"first_ts": 300_000, "count": 7, "leverage": 3, "warmup_bars": 2},
+        {"first_ts": 600_000, "count": 7, "leverage": 3, "warmup_bars": 2},
+    ]
+    assert metrics["sample_count"] == 3
+    assert metrics["avg_return"] == pytest.approx(0.0066666667)
+    assert metrics["median_return"] == pytest.approx(0.01)
+    assert metrics["positive_window_rate"] == pytest.approx(2 / 3)
+    assert metrics["worst_return"] == pytest.approx(-0.02)
+    assert metrics["p10_return"] == pytest.approx(-0.014)
+    assert metrics["p90_return"] == pytest.approx(0.026)
+    assert metrics["best_return"] == pytest.approx(0.03)
+    assert metrics["trades"] == 6
+    assert metrics["avg_trades_per_window"] == pytest.approx(2.0)
+    assert metrics["win_rate"] == pytest.approx(0.5)
+    assert metrics["trade_win_rate"] == pytest.approx(0.5)
+    assert metrics["avg_trade_return"] == pytest.approx(0.005)
+    assert metrics["avg_win_return"] == pytest.approx(0.02)
+    assert metrics["avg_loss_return_abs"] == pytest.approx(0.01)
+    assert metrics["payoff_ratio"] == pytest.approx(2.0)
+    assert metrics["profit_factor"] == pytest.approx(2.0)
+    assert metrics["expectancy_per_trade"] == pytest.approx(0.005)
+    assert metrics["max_drawdown"] == pytest.approx(0.04)
+    assert metrics["return_drawdown_ratio"] == pytest.approx(0.0066666667 / 0.04)
+    assert metrics["ci95_low"] < metrics["avg_return"] < metrics["ci95_high"]
+
+
+def test_evaluate_candidate_window_rows_keeps_window_timestamps():
+    module = load_explore_module()
+
+    def run_segment(*, candles, leverage, warmup_bars):
+        return build_result(
+            0.01,
+            trade_count=1,
+            win_rate=1.0,
+            max_drawdown=0.02,
+            trade_returns=[0.01],
+        )
+
+    rows = module.evaluate_candidate_window_rows(
+        candidate=module.Candidate("test-candidate", warmup_bars=2, run=run_segment),
+        candles=build_candles(12),
+        window_size=5,
+        leverage=3,
+    )
+
+    assert rows[0]["window_start_ts"] == 120_000
+    assert rows[0]["window_end_ts"] == 360_000
+    assert rows[1]["window_start_ts"] == 420_000
+    assert rows[1]["window_end_ts"] == 660_000
+    assert module.summarize_window_rows(rows)["sample_count"] == 2
+
+
+def test_sort_robust_results_prioritizes_confidence_interval_lower_bound():
+    module = load_explore_module()
+    pandas = pytest.importorskip("pandas")
+    frame = pandas.DataFrame(
+        [
+            {"name": "higher-average", "avg_return": 0.10, "ci95_low": -0.01},
+            {"name": "supported", "avg_return": 0.03, "ci95_low": 0.01},
+            {"name": "tie-breaker", "avg_return": 0.04, "ci95_low": 0.01},
+        ]
+    )
+
+    sorted_frame = module.sort_robust_results(frame)
+
+    assert sorted_frame["name"].tolist() == ["tie-breaker", "supported", "higher-average"]
+
+
+def test_add_cost_metrics_and_sort_cost_results():
+    module = load_explore_module()
+    pandas = pytest.importorskip("pandas")
+    frame = pandas.DataFrame(
+        [
+            {"name": "active", "avg_return": 0.01, "ci95_low": 0.004, "ci95_high": 0.016, "avg_trades_per_window": 10.0},
+            {"name": "selective", "avg_return": 0.006, "ci95_low": 0.003, "ci95_high": 0.009, "avg_trades_per_window": 2.0},
+        ]
+    )
+
+    with_cost = module.add_cost_metrics(frame, 0.0012)
+    sorted_frame = module.sort_cost_results(with_cost)
+
+    assert with_cost.loc[0, "net_avg_return"] == pytest.approx(-0.002)
+    assert with_cost.loc[0, "net_ci95_low"] == pytest.approx(-0.008)
+    assert with_cost.loc[0, "breakeven_roundtrip_cost_on_margin"] == pytest.approx(0.001)
+    assert with_cost.loc[1, "net_avg_return"] == pytest.approx(0.0036)
+    assert with_cost.loc[1, "net_ci95_low"] == pytest.approx(0.0006)
+    assert sorted_frame["name"].tolist() == ["selective", "active"]
+
+
+def test_summarize_periods_applies_trade_cost_to_period_average():
+    module = load_explore_module()
+    pandas = pytest.importorskip("pandas")
+    frame = pandas.DataFrame(
+        [
+            {"window_end_ts": 0, "total_return": 0.01, "trade_count": 2, "win_rate": 0.5, "max_drawdown": 0.01},
+            {"window_end_ts": 86_400_000, "total_return": -0.02, "trade_count": 1, "win_rate": 0.0, "max_drawdown": 0.03},
+        ]
+    )
+
+    monthly = module.summarize_periods(frame, "M", 0.001)
+
+    assert monthly.loc[0, "period"] == "1970-01"
+    assert monthly.loc[0, "window_count"] == 2
+    assert monthly.loc[0, "avg_return"] == pytest.approx(-0.005)
+    assert monthly.loc[0, "positive_window_rate"] == pytest.approx(0.5)
+    assert monthly.loc[0, "trades"] == 3
+    assert monthly.loc[0, "net_avg_return"] == pytest.approx(-0.0065)
+
+
+def test_summarize_cost_adjusted_trade_equity_periods_compounds_net_trade_returns():
+    module = load_explore_module()
+    result = build_result(
+        0.0,
+        trade_count=2,
+        win_rate=0.5,
+        max_drawdown=0.0,
+        trade_returns=[0.01, -0.02],
+        equity_curve=[{"ts": 1_775_001_600_000, "equity": 10_000.0, "close": 100.0}],
+    )
+
+    monthly = module.summarize_cost_adjusted_trade_equity_periods(result, "M", 0.001)
+
+    assert monthly.loc[0, "period"] == "2026-04"
+    assert monthly.loc[0, "trades"] == 2
+    assert monthly.loc[0, "end_equity"] == pytest.approx(10_000.0 * 1.009 * 0.979)
+
+
+def test_add_market_regime_columns_adds_net_return_and_buckets():
+    module = load_explore_module()
+    rows = [
+        {"window_start_ts": 300 * 60_000, "window_end_ts": 359 * 60_000, "total_return": 0.02, "trade_count": 2, "win_rate": 0.5, "max_drawdown": 0.01, "trades": []},
+        {"window_start_ts": 360 * 60_000, "window_end_ts": 419 * 60_000, "total_return": -0.01, "trade_count": 1, "win_rate": 0.0, "max_drawdown": 0.02, "trades": []},
+        {"window_start_ts": 420 * 60_000, "window_end_ts": 479 * 60_000, "total_return": 0.03, "trade_count": 3, "win_rate": 1.0, "max_drawdown": 0.01, "trades": []},
+    ]
+
+    frame = module.add_market_regime_columns(build_candles(500), rows, 0.001)
+
+    assert frame.loc[0, "net_return"] == pytest.approx(0.018)
+    assert set(frame["market_return_bucket"].astype(str)) <= {"down", "flat", "up"}
+    assert "realized_vol" in frame
+    assert "ma240_distance" in frame
+
+
+def test_annualized_metrics_from_cost_adjusted_equity():
+    module = load_explore_module()
+    pandas = pytest.importorskip("pandas")
+    frame = pandas.DataFrame(
+        [
+            {"ts": pandas.Timestamp("2025-01-01", tz="UTC"), "equity": 100.0},
+            {"ts": pandas.Timestamp("2025-01-02", tz="UTC"), "equity": 120.0},
+            {"ts": pandas.Timestamp("2025-01-03", tz="UTC"), "equity": 90.0},
+            {"ts": pandas.Timestamp("2026-01-01", tz="UTC"), "equity": 110.0},
+        ]
+    )
+
+    metrics = module.annualized_metrics_from_equity(frame, 1_735_689_600_000, 1_767_225_600_000)
+
+    assert metrics["net_total_return"] == pytest.approx(0.10)
+    assert metrics["net_annualized_return"] == pytest.approx(0.10)
+    assert metrics["net_max_drawdown"] == pytest.approx(0.25)
+    assert metrics["net_calmar"] == pytest.approx(0.4)
+    assert "net_sharpe_daily" in metrics
+
+
+def test_recent_horizon_metrics_use_equity_at_cutoff():
+    module = load_explore_module()
+    pandas = pytest.importorskip("pandas")
+    frame = pandas.DataFrame(
+        [
+            {"ts": pandas.Timestamp("2024-01-01 00:00", tz="UTC"), "equity": 100.0},
+            {"ts": pandas.Timestamp("2025-06-01 00:00", tz="UTC"), "equity": 120.0},
+            {"ts": pandas.Timestamp("2025-12-01 00:00", tz="UTC"), "equity": 90.0},
+            {"ts": pandas.Timestamp("2026-04-01 00:00", tz="UTC"), "equity": 110.0},
+        ]
+    )
+
+    rows = module.recent_horizon_metrics_from_equity(
+        frame,
+        int(pandas.Timestamp("2026-04-01 00:00", tz="UTC").timestamp() * 1000),
+        (("1y", pandas.DateOffset(years=1)), ("3m", pandas.DateOffset(months=3))),
+    )
+
+    assert rows.loc[0, "horizon"] == "1y"
+    assert rows.loc[0, "horizon_start"] == "2025-04-01 00:00"
+    assert rows.loc[0, "net_total_return"] == pytest.approx(110.0 / 100.0 - 1.0)
+    assert rows.loc[1, "horizon"] == "3m"
+    assert rows.loc[1, "horizon_start"] == "2026-01-01 00:00"
+    assert rows.loc[1, "net_total_return"] == pytest.approx(110.0 / 90.0 - 1.0)
+
+
+def test_build_ma_cross_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_ma_cross_candidate(20, 80, "long")
+
+    assert candidate.name == "ma-cross-long-f20-s80"
+    assert candidate.warmup_bars == 80
+
+
+def test_build_rsi2_long_guarded_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_rsi2_long_guarded_candidate(240, 2.0, 55.0, 0.008, 96)
+
+    assert candidate.name == "rsi2-long-guarded-t240-l2.0-x55.0-sl0.008-mh96"
+    assert candidate.warmup_bars == 240
+
+
+def test_build_trend_rsi_bb_long_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_trend_rsi_bb_long_candidate(240, 20, 2.5, 3.0, 45.0, 0.005)
+
+    assert candidate.name == "trend-rsi-bb-long-t240-b20-m2.5-r3.0-x45.0-sl0.005"
+    assert candidate.warmup_bars == 240
+
+
+def test_build_regime_hybrid_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_regime_hybrid_candidate(240, 240, 0.015, 2.0, 55.0, 2.5, 0.008)
+
+    assert candidate.name == "regime-hybrid-t240-r240-n0.015-l2.0-x55.0-m2.5-sl0.008"
+    assert candidate.warmup_bars == 240
+
+
+def test_align_pair_candles_keeps_shared_timestamps():
+    module = load_explore_module()
+    left = build_candles(4)
+    right = [
+        Candle(symbol="BTC-USDT-SWAP", ts=60_000, open=1.0, high=1.0, low=1.0, close=1.0, volume=1.0),
+        Candle(symbol="BTC-USDT-SWAP", ts=180_000, open=3.0, high=3.0, low=3.0, close=3.0, volume=1.0),
+    ]
+
+    left_aligned, right_aligned = module.align_pair_candles(left, right)
+
+    assert [candle.ts for candle in left_aligned] == [60_000, 180_000]
+    assert [candle.ts for candle in right_aligned] == [60_000, 180_000]
+
+
+def test_build_eth_btc_rsi_filter_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_eth_btc_rsi_filter_candidate(50, 3.0, 55.0, 240, 96, 0.01)
+
+    assert candidate.name == "eth-btc-rsi-filter-et50-l3.0-x55.0-bt240-bm96-br0.01"
+    assert candidate.warmup_bars == 240
+
+
+def test_build_eth_btc_shock_filter_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_eth_btc_shock_filter_candidate(50, 3.0, 55.0, 480, 240, 0.0, 240, 0.006, 0.05)
+
+    assert candidate.name == "eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.0-sw240-sv0.006-sd0.05"
+    assert candidate.warmup_bars == 480
+
+
+def test_build_eth_btc_ratio_pullback_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_eth_btc_ratio_pullback_candidate(480, 96, 0.01, 48, 2.0, 5.0, 0.008)
+
+    assert candidate.name == "eth-btc-ratio-pullback-bt480-bm96-br0.01-rl48-rs2.0-rr5.0-sl0.008"
+    assert candidate.warmup_bars == 480
+
+
+def test_build_btc_lead_eth_lag_candidate_names_and_warmup():
+    module = load_explore_module()
+
+    candidate = module.build_btc_lead_eth_lag_candidate(16, 0.018, 0.012, 32, 0.006, 0.012)
+
+    assert candidate.name == "btc-lead-eth-lag-lb16-br0.018-gap0.012-mh32-sl0.006-tp0.012"
+    assert candidate.warmup_bars == 16
+
+
+def test_btc_lead_eth_lag_enters_after_btc_outperformance():
+    module = load_explore_module()
+    eth = build_candles(8)
+    btc = build_candles(8)
+    btc[3] = Candle(symbol="BTC-USDT-SWAP", ts=180_000, open=100.0, high=100.0, low=100.0, close=103.0, volume=1.0)
+    btc[4] = Candle(symbol="BTC-USDT-SWAP", ts=240_000, open=103.0, high=103.0, low=103.0, close=103.0, volume=1.0)
+    eth[3] = Candle(symbol="ETH-USDT-SWAP", ts=180_000, open=100.0, high=100.0, low=100.0, close=100.0, volume=1.0)
+    eth[4] = Candle(symbol="ETH-USDT-SWAP", ts=240_000, open=100.0, high=102.0, low=100.0, close=101.0, volume=1.0)
+
+    result = module.run_btc_lead_eth_lag_segment(
+        eth_candles=eth,
+        btc_candles=btc,
+        leverage=3,
+        warmup_bars=3,
+        lead_lookback=3,
+        btc_return_threshold=0.02,
+        lag_gap=0.02,
+        max_hold_bars=3,
+        stop_loss_pct=0.01,
+        take_profit_pct=0.01,
+    )
+
+    assert result.trade_count == 1
+    assert result.trades[0]["side"] == "Long"
+    assert result.total_return > 0.0
+
+
+def test_get_candles_cached_saves_exhausted_history_and_updates_latest(tmp_path):
+    module = load_explore_module()
+
+    class Client:
+        def __init__(self):
+            self.limits: list[int] = []
+
+        def get_candles(self, symbol, bar, limit):
+            self.limits.append(limit)
+            if len(self.limits) == 1:
+                return build_candles(3)
+            return [
+                Candle(
+                    symbol=symbol,
+                    ts=3 * 60_000,
+                    open=103.0,
+                    high=104.0,
+                    low=102.0,
+                    close=103.0,
+                    volume=1_003.0,
+                )
+            ]
+
+    client = Client()
+
+    first = module.get_candles_cached(client, "BTC-USDT-SWAP", "15m", 500, tmp_path)
+    second = module.get_candles_cached(client, "BTC-USDT-SWAP", "15m", 500, tmp_path)
+
+    assert [candle.ts for candle in first] == [0, 60_000, 120_000]
+    assert [candle.ts for candle in second] == [0, 60_000, 120_000, 180_000]
+    assert client.limits == [500, 300]
+    assert (tmp_path / "BTC-USDT-SWAP" / "15m.csv").exists()
+    assert module.load_cached_candles(tmp_path, "BTC-USDT-SWAP", "15m")[1] is True
+
+
+def test_history_bars_for_years_counts_minute_bars():
+    module = load_explore_module()
+
+    assert module.history_bars_for_years("15m", 10.0) == 350_400
+    assert module.history_bars_for_years("3m", 10.0) == 1_752_000
+
+
+def test_build_strategy_timeframe_candidates_uses_fixed_strategy_set():
+    module = load_explore_module()
+
+    candidates = module.build_strategy_timeframe_candidates()
+
+    assert [candidate.name for candidate in candidates] == [
+        "bbmr-default",
+        "bbsb-default",
+        "donchian-e12-x6-s0.008",
+        "rsi2-t50-l3.0-s97.0",
+        "ema-pullback-f13-s34-b0.006",
+        "range-momo-l10-tp0.006-sl0.004",
+        "vwap-revert-w72-z2.0-sl0.006",
+    ]