| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- from __future__ import annotations
- import argparse
- import sys
- from dataclasses import replace
- from pathlib import Path
- import pandas as pd
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from scripts.search_eth_bearish_failure_confirmation import (
- INITIAL_EQUITY,
- Spec,
- close_return,
- joined_frames,
- load_frame,
- markdown_table,
- period_metrics,
- resample,
- signals,
- )
- from scripts.search_long_short_fusion import component_returns, metrics
- OUTPUT_DIR = Path("reports/eth-exploration")
- PREFIX = "trend-exhaustion-narrow-validation"
- BASELINE_EQUITY = Path("reports/eth-exploration/eth-focused-portfolio-conservative-equity.csv")
- BASELINE_PORTFOLIO = "all_legs-risk-3-c0124-eth_btc_rsi_filter+btc_lead_eth_lag_15m+eth_robust_twap"
- CANDIDATE = Spec("trend_exhaustion", "1H", 50, 240, 8, 0.012, 0.03, 0.045, 72, "none")
- HORIZONS = (
- ("full", None),
- ("3y", pd.DateOffset(years=3)),
- ("1y", pd.DateOffset(years=1)),
- ("6m", pd.DateOffset(months=6)),
- ("3m", pd.DateOffset(months=3)),
- )
- OVERLAY_WEIGHTS = (0.025, 0.05, 0.075, 0.10)
- def run_spec(spec: Spec, frame: pd.DataFrame, entry_filter: pd.Series | None = None) -> tuple[pd.Series, list[dict[str, object]]]:
- entry, exit_ = signals(spec, frame)
- if entry_filter is not None:
- entry = entry & entry_filter.reindex(entry.index).fillna(False)
- warmup = max(spec.slow, 260, spec.lookback * 3) + 2
- equity = INITIAL_EQUITY
- position: dict[str, object] | None = None
- pending_entry = False
- pending_exit = False
- trades: list[dict[str, object]] = []
- curve: list[tuple[pd.Timestamp, float]] = []
- rows = list(frame.itertuples())
- for index in range(warmup, len(rows)):
- candle = rows[index]
- ts = frame.index[index]
- if pending_exit and position is not None:
- net = close_return(float(position["entry_price"]), float(candle.open))
- equity *= 1 + net
- trades.append({"entry_time": position["entry_time"], "exit_time": ts, "return": net})
- position = None
- pending_exit = False
- if pending_entry and position is None and equity > 0:
- position = {
- "entry_time": ts,
- "entry_index": index,
- "entry_price": float(candle.open),
- "stop": float(candle.open) * (1 + spec.stop),
- "take": float(candle.open) * (1 - spec.take),
- }
- pending_entry = False
- mark = equity
- if position is not None:
- stop_hit = candle.high >= float(position["stop"])
- take_hit = candle.low <= float(position["take"])
- if stop_hit or take_hit:
- price = float(position["stop"] if stop_hit else position["take"])
- net = close_return(float(position["entry_price"]), price)
- equity *= 1 + net
- trades.append({"entry_time": position["entry_time"], "exit_time": ts, "return": net})
- position = None
- mark = equity
- else:
- gross = float(position["entry_price"]) / candle.close - 1
- mark = equity * (1 + gross - 0.0004)
- curve.append((ts, mark))
- if index == len(rows) - 1 or equity <= 0:
- continue
- if position is None and bool(entry.iloc[index]):
- pending_entry = True
- elif position is not None and (bool(exit_.iloc[index]) or index - int(position["entry_index"]) >= spec.hold):
- pending_exit = True
- series = pd.Series({ts: value for ts, value in curve}).sort_index()
- daily = series.resample("1D").last().ffill()
- daily = pd.concat([pd.Series([INITIAL_EQUITY], index=[daily.index[0].normalize()]), daily]).sort_index()
- return daily.groupby(level=0).last(), trades
- def row_for_spec(name: str, equity: pd.Series, trades: list[dict[str, object]]) -> dict[str, object]:
- row: dict[str, object] = {"name": name}
- for label, offset in HORIZONS:
- for key, value in period_metrics(equity, trades, offset).items():
- row[f"{label}_{key}"] = value
- years = yearly_returns(equity)
- row["return_2022"] = float(years.get("2022", 0.0))
- row["return_2023"] = float(years.get("2023", 0.0))
- row["worst_year"] = float(years.min()) if len(years) else 0.0
- row["left_tail_2022_2023"] = min(row["return_2022"], row["return_2023"])
- return row
- def yearly_returns(equity: pd.Series) -> pd.Series:
- sampled = equity.resample("YE").last().dropna()
- starts = equity.resample("YE").first().reindex(sampled.index)
- returns = sampled / starts - 1.0
- returns.index = sampled.index.tz_localize(None).to_period("Y").astype(str)
- return returns
- def neighborhood_specs() -> list[Spec]:
- specs = {CANDIDATE.name: CANDIDATE}
- for fast, slow in ((40, 220), (40, 240), (50, 220), (50, 260), (60, 240), (60, 260)):
- spec = replace(CANDIDATE, fast=fast, slow=slow)
- specs[spec.name] = spec
- for field, values in (
- ("lookback", (6, 10)),
- ("threshold", (0.010, 0.014)),
- ("stop", (0.025, 0.035)),
- ("take", (0.040, 0.050)),
- ("hold", (60, 84)),
- ):
- for value in values:
- spec = replace(CANDIDATE, **{field: value})
- specs[spec.name] = spec
- return list(specs.values())
- def feature_filters(frame: pd.DataFrame) -> dict[str, pd.Series]:
- close = frame["close"]
- btc = frame["btc_close"]
- slow = close.ewm(span=CANDIDATE.slow, adjust=False).mean()
- eth_slope = slow / slow.shift(24) - 1.0
- btc_sma = btc.rolling(240).mean()
- btc_slope = btc_sma / btc_sma.shift(24) - 1.0
- eth_vol = close.pct_change().rolling(72).std()
- vol_rank = eth_vol.rolling(720).rank(pct=True)
- return {
- "btc_above_sma240": btc > btc_sma,
- "btc_sma240_slope_ge_0": btc_slope >= 0.0,
- "eth_slope_ge_-0.015": eth_slope >= -0.015,
- "eth_slope_-0.015_to_0": (eth_slope >= -0.015) & (eth_slope <= 0.0),
- "vol_rank_0.35_to_0.85": (vol_rank >= 0.35) & (vol_rank <= 0.85),
- "vol_rank_le_0.85": vol_rank <= 0.85,
- "btc_up_and_mid_vol": (btc > btc_sma) & (vol_rank >= 0.35) & (vol_rank <= 0.85),
- "btc_up_eth_slope_ge_-0.015": (btc > btc_sma) & (eth_slope >= -0.015),
- "btc_up_mid_vol_eth_slope": (btc > btc_sma) & (vol_rank >= 0.35) & (vol_rank <= 0.85) & (eth_slope >= -0.015),
- "btc_slope_up_mid_vol_eth_slope": (btc_slope >= 0.0) & (vol_rank >= 0.35) & (vol_rank <= 0.85) & (eth_slope >= -0.015),
- }
- def baseline_equity(path: Path, portfolio: str) -> pd.Series:
- frame = pd.read_csv(path)
- selected = frame[
- (frame["portfolio"] == portfolio)
- & (frame["cost_model"] == "maker_taker")
- & (frame["scope"] == "all_legs")
- ].copy()
- selected["date"] = pd.to_datetime(selected["date"], utc=True)
- series = selected.sort_values("date").set_index("date")["equity"].astype(float)
- series.name = portfolio
- return series
- def overlay_rows(base: pd.Series, overlay: pd.Series) -> pd.DataFrame:
- base_metrics = {label: horizon_metrics(base, offset) for label, offset in HORIZONS}
- rows = []
- overlay_returns = component_returns(overlay)
- for weight in OVERLAY_WEIGHTS:
- aligned = pd.DataFrame({"base": component_returns(base), "overlay": overlay_returns}).dropna()
- combined = aligned["base"] + aligned["overlay"] * weight
- equity = INITIAL_EQUITY * (1.0 + combined).cumprod()
- for label, offset in HORIZONS:
- row = horizon_metrics(equity, offset)
- baseline = base_metrics[label]
- rows.append(
- {
- "overlay_weight": weight,
- "horizon": label,
- **row,
- "baseline_total_return": baseline["total_return"],
- "baseline_max_drawdown": baseline["max_drawdown"],
- "baseline_calmar": baseline["calmar"],
- "delta_total_return": row["total_return"] - baseline["total_return"],
- "delta_max_drawdown": row["max_drawdown"] - baseline["max_drawdown"],
- "delta_calmar": row["calmar"] - baseline["calmar"],
- }
- )
- return pd.DataFrame(rows)
- def horizon_metrics(series: pd.Series, offset: pd.DateOffset | None) -> dict[str, object]:
- scoped = series if offset is None else series[series.index >= series.index[-1] - offset]
- if len(scoped) < 2:
- scoped = series
- return {"start": scoped.index[0].strftime("%Y-%m-%d"), "end": scoped.index[-1].strftime("%Y-%m-%d"), **metrics(scoped)}
- def report_text(paths: list[Path], neighborhood: pd.DataFrame, filters: pd.DataFrame, overlay: pd.DataFrame, selected_name: str) -> str:
- base = filters[filters["name"] == "unfiltered"].iloc[0]
- best_filter = filters.iloc[0]
- full_overlay = overlay[overlay["horizon"] == "full"].sort_values(["delta_calmar", "delta_max_drawdown"], ascending=[False, True])
- best_overlay = full_overlay.iloc[0]
- neighborhood_pass = int(
- (
- (neighborhood["full_total_return"] > 0.0)
- & (neighborhood["3y_total_return"] > 0.0)
- & (neighborhood["1y_total_return"] > 0.0)
- & (neighborhood["return_2022"] > -0.10)
- & (neighborhood["return_2023"] > -0.05)
- ).sum()
- )
- include = bool(
- neighborhood_pass >= 5
- and best_filter["return_2022"] > -0.10
- and best_filter["return_2023"] > -0.05
- and best_filter["full_total_return"] > 0.0
- and best_overlay["delta_calmar"] > 0.0
- and best_overlay["delta_max_drawdown"] <= 0.0
- )
- verdict = (
- f"Include `{CANDIDATE.name}` with `{selected_name}` as a capped 0.025-0.10 overlay dimension in the conservative ETH portfolio search."
- if include
- else f"Reject `{CANDIDATE.name}` for portfolio search. The narrow validation does not satisfy stability, left-tail, and overlay drawdown/Calmar requirements together."
- )
- keep = [
- "name",
- "full_total_return",
- "full_annualized_return",
- "full_max_drawdown",
- "full_profit_factor",
- "full_trades",
- "3y_total_return",
- "1y_total_return",
- "6m_total_return",
- "3m_total_return",
- "return_2022",
- "return_2023",
- "worst_year",
- ]
- overlay_keep = [
- "overlay_weight",
- "horizon",
- "total_return",
- "max_drawdown",
- "calmar",
- "delta_total_return",
- "delta_max_drawdown",
- "delta_calmar",
- ]
- return "\n".join(
- [
- "# Trend Exhaustion Narrow Validation",
- "",
- "Run command: `rtk .venv/bin/python scripts/validate_trend_exhaustion_candidate.py`",
- "",
- "Output files:",
- *[f"- `{path}`" for path in paths],
- "",
- f"Candidate: `{CANDIDATE.name}`.",
- "Scope: local OKX `ETH-USDT-SWAP` and `BTC-USDT-SWAP` candles only. No live path touched.",
- "All filters use current or historical completed 1H candles; entries execute on the next open.",
- "",
- "## Small Parameter Neighborhood",
- "",
- f"Neighborhood pass count under fixed left-tail thresholds: {neighborhood_pass}/{len(neighborhood)}.",
- "",
- markdown_table(neighborhood.sort_values(["full_total_return", "left_tail_2022_2023"], ascending=[False, False]).head(12)[keep]),
- "",
- "## Structural Filters",
- "",
- "Filter objective: improve 2022/2023 left tail without using future candles.",
- "",
- markdown_table(filters.head(12)[keep]),
- "",
- "Unfiltered 2022/2023:",
- f"- 2022 `{base['return_2022']:.4f}`, 2023 `{base['return_2023']:.4f}`",
- "",
- "## Conservative Portfolio Overlay",
- "",
- f"Overlay source: `{CANDIDATE.name}` filtered by `{selected_name}`.",
- "",
- markdown_table(overlay[overlay["horizon"].isin(["full", "3y", "1y"])][overlay_keep]),
- "",
- "## Verdict",
- "",
- verdict,
- "",
- ]
- )
- def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
- parser.add_argument("--baseline-equity", type=Path, default=BASELINE_EQUITY)
- parser.add_argument("--baseline-portfolio", default=BASELINE_PORTFOLIO)
- args = parser.parse_args()
- eth = load_frame("ETH-USDT-SWAP")
- btc = load_frame("BTC-USDT-SWAP")
- frame = joined_frames(resample(eth, "1H"), resample(btc, "1H"))
- neighborhood_rows = []
- best_equity: pd.Series | None = None
- selected_name = ""
- for spec in neighborhood_specs():
- equity, trades = run_spec(spec, frame)
- row = row_for_spec(spec.name, equity, trades)
- neighborhood_rows.append(row)
- if spec == CANDIDATE:
- best_equity = equity
- selected_name = spec.name
- neighborhood = pd.DataFrame(neighborhood_rows)
- filter_rows = []
- filter_equities: dict[str, pd.Series] = {}
- equity, trades = run_spec(CANDIDATE, frame)
- filter_rows.append(row_for_spec("unfiltered", equity, trades))
- filter_equities["unfiltered"] = equity
- for name, mask in feature_filters(frame).items():
- equity, trades = run_spec(CANDIDATE, frame, mask)
- filter_rows.append(row_for_spec(name, equity, trades))
- filter_equities[name] = equity
- filters = pd.DataFrame(filter_rows).sort_values(
- ["left_tail_2022_2023", "full_max_drawdown", "full_total_return"],
- ascending=[False, True, False],
- )
- selected_filter_name = str(filters.iloc[0]["name"])
- selected_equity = filter_equities[selected_filter_name]
- if best_equity is None:
- best_equity = filter_equities["unfiltered"]
- selected_name = CANDIDATE.name
- overlay = overlay_rows(baseline_equity(args.baseline_equity, args.baseline_portfolio), selected_equity)
- args.output_dir.mkdir(parents=True, exist_ok=True)
- neighborhood_path = args.output_dir / f"{PREFIX}-neighborhood.csv"
- filters_path = args.output_dir / f"{PREFIX}-filters.csv"
- overlay_path = args.output_dir / f"{PREFIX}-overlay.csv"
- report_path = args.output_dir / f"{PREFIX}.md"
- neighborhood.to_csv(neighborhood_path, index=False)
- filters.to_csv(filters_path, index=False)
- overlay.to_csv(overlay_path, index=False)
- report_path.write_text(
- report_text(
- [neighborhood_path, filters_path, overlay_path, report_path],
- neighborhood,
- filters,
- overlay,
- selected_filter_name,
- ),
- encoding="utf-8",
- )
- print(report_path)
- print(filters.head(8).to_string(index=False))
- print(overlay[overlay["horizon"] == "full"].to_string(index=False))
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|