| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- from __future__ import annotations
- import argparse
- import sys
- from dataclasses import dataclass
- from itertools import product
- from pathlib import Path
- import pandas as pd
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
- from scripts import refine_expansion_rotation_risk as rotation_risk
- from scripts import search_expansion_rotation as rotation
- OUTPUT_DIR = Path("reports/short-bias")
- PREFIX = "overlay"
- SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
- INITIAL_EQUITY = 10_000.0
- TAKER_FEE = 0.0004
- HORIZONS = (
- ("full", None),
- ("3y", pd.DateOffset(years=3)),
- ("1y", pd.DateOffset(years=1)),
- ("6m", pd.DateOffset(months=6)),
- ("3m", pd.DateOffset(months=3)),
- )
- @dataclass(frozen=True)
- class ShortParams:
- family: str
- bar: str
- trend: int
- lookback: int
- vol_lookback: int
- min_vol: float
- max_momentum: float
- drop: float
- @property
- def name(self) -> str:
- return (
- f"{self.family}-{self.bar}-tr{self.trend}-lb{self.lookback}"
- f"-vw{self.vol_lookback}-mv{self.min_vol:.4f}"
- f"-mm{self.max_momentum:.3f}-dp{self.drop:.3f}"
- )
- def build_short_params() -> list[ShortParams]:
- params: list[ShortParams] = []
- for bar in ("1h", "4h"):
- if bar == "1h":
- trends = (24 * 30, 24 * 60)
- lookbacks = (24 * 7, 24 * 14)
- vols = (24 * 14,)
- min_vols = (0.010, 0.014, 0.018)
- drops = (0.025, 0.040)
- else:
- trends = (6 * 30, 6 * 60)
- lookbacks = (6 * 7, 6 * 14)
- vols = (6 * 14,)
- min_vols = (0.020, 0.028, 0.036)
- drops = (0.030, 0.050)
- for family, trend, lookback, vol_lookback, min_vol, max_momentum, drop in product(
- ("risk_off_pair", "trend_breakdown", "high_vol_breakdown"),
- trends,
- lookbacks,
- vols,
- min_vols,
- (-0.005, 0.000),
- drops,
- ):
- params.append(ShortParams(family, bar, trend, lookback, vol_lookback, min_vol, max_momentum, drop))
- return params
- def short_weights(closes: pd.DataFrame, params: ShortParams) -> pd.DataFrame:
- returns = closes.pct_change()
- momentum = closes / closes.shift(params.lookback) - 1.0
- trend = closes.rolling(params.trend).mean()
- vol = returns.rolling(params.vol_lookback).std(ddof=1) * (365 * {"1h": 24, "4h": 6}[params.bar]) ** 0.5
- btc = "BTC-USDT-SWAP"
- weights = pd.DataFrame(0.0, index=closes.index, columns=closes.columns)
- if params.family == "risk_off_pair":
- state = (
- (closes[btc] < trend[btc])
- & (momentum[btc] <= params.max_momentum)
- & (vol[btc] >= params.min_vol)
- )
- weights.loc[state, list(SYMBOLS)] = -0.5
- elif params.family == "trend_breakdown":
- eligible = (closes < trend) & (momentum <= params.max_momentum) & (vol >= params.min_vol)
- counts = eligible[list(SYMBOLS)].sum(axis=1)
- for symbol in SYMBOLS:
- weights.loc[eligible[symbol] & (counts > 0), symbol] = -1.0 / counts[eligible[symbol] & (counts > 0)]
- elif params.family == "high_vol_breakdown":
- drawdown = closes / closes.shift(params.lookback) - 1.0
- state = (
- (closes[btc] < trend[btc])
- & (drawdown[btc] <= -params.drop)
- & (vol[btc] >= params.min_vol)
- )
- eligible = (closes < trend) & (drawdown <= 0.0)
- counts = eligible[list(SYMBOLS)].sum(axis=1)
- for symbol in SYMBOLS:
- mask = state & eligible[symbol] & (counts > 0)
- weights.loc[mask, symbol] = -1.0 / counts[mask]
- else:
- raise ValueError(f"unknown family {params.family}")
- return weights.fillna(0.0)
- def equity_from_weights(closes: pd.DataFrame, weights: pd.DataFrame) -> pd.Series:
- returns = closes.pct_change().fillna(0.0)
- executed = weights.shift(1).fillna(0.0)
- turnover = executed.diff().abs().sum(axis=1).fillna(executed.abs().sum(axis=1))
- net_returns = (executed * returns).sum(axis=1) - turnover * TAKER_FEE
- equity = INITIAL_EQUITY * (1.0 + net_returns).cumprod()
- equity.name = "equity"
- return equity
- def metrics(series: pd.Series) -> dict[str, float]:
- years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
- total = float(series.iloc[-1] / series.iloc[0] - 1.0)
- annualized = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
- drawdown = float((series.cummax() - series).div(series.cummax()).max())
- return {
- "total_return": total,
- "annualized_return": annualized,
- "max_drawdown": drawdown,
- "calmar": annualized / drawdown if drawdown else 0.0,
- }
- def horizon_rows(name: str, kind: str, series: pd.Series) -> list[dict[str, object]]:
- rows: list[dict[str, object]] = []
- end = series.index[-1]
- for label, offset in HORIZONS:
- horizon = series if offset is None else series[series.index >= end - offset]
- if len(horizon) < 2:
- horizon = series
- rows.append(
- {
- "name": name,
- "kind": kind,
- "horizon": label,
- "start": horizon.index[0].strftime("%Y-%m-%d"),
- "end": horizon.index[-1].strftime("%Y-%m-%d"),
- **metrics(horizon),
- }
- )
- return rows
- def horizon_return_fields(series: pd.Series) -> dict[str, float]:
- rows = {row["horizon"]: row for row in horizon_rows("", "", series)}
- return {
- "h3y_return": float(rows["3y"]["total_return"]),
- "h1y_return": float(rows["1y"]["total_return"]),
- "h6m_return": float(rows["6m"]["total_return"]),
- "h3m_return": float(rows["3m"]["total_return"]),
- }
- def monthly_rows(name: str, kind: str, series: pd.Series) -> pd.DataFrame:
- monthly = series.resample("ME").last()
- frame = pd.DataFrame(
- {
- "name": name,
- "kind": kind,
- "month": monthly.index.strftime("%Y-%m"),
- "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
- "end_equity": monthly.to_numpy(),
- }
- )
- frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
- return frame
- def worst_rolling_rows(name: str, kind: str, monthly: pd.DataFrame) -> list[dict[str, object]]:
- rows: list[dict[str, object]] = []
- returns = monthly.set_index("month")["return"]
- for window in (6, 12):
- rolling = (1.0 + returns).rolling(window).apply(lambda values: float(values.prod() - 1.0), raw=True)
- worst_end = rolling.idxmin()
- rows.append(
- {
- "name": name,
- "kind": kind,
- "window_months": window,
- "end_month": worst_end,
- "return": float(rolling.loc[worst_end]),
- }
- )
- return rows
- def trade_stats(weights: pd.DataFrame, closes: pd.DataFrame) -> dict[str, float | int]:
- executed = weights.shift(1).fillna(0.0)
- returns = closes.pct_change().fillna(0.0)
- turnover = executed.diff().abs().fillna(executed.abs())
- trades = 0
- wins = 0
- gross_profit = 0.0
- gross_loss = 0.0
- for symbol in SYMBOLS:
- active = executed[symbol] < 0.0
- groups = (active != active.shift(1)).cumsum()
- for _, mask in active.groupby(groups):
- if not bool(mask.iloc[0]):
- continue
- index = mask.index
- net = returns.loc[index, symbol] * executed.loc[index, symbol] - turnover.loc[index, symbol] * TAKER_FEE
- value = float((1.0 + net).prod() - 1.0)
- trades += 1
- if value > 0.0:
- wins += 1
- gross_profit += value
- else:
- gross_loss += abs(value)
- return {
- "trades": trades,
- "win_rate": wins / trades if trades else 0.0,
- "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
- }
- def rotation_risk_equity(years: float) -> tuple[str, pd.Series]:
- top = pd.read_csv(rotation_risk.OUTPUT_DIR / "rotation-risk-top.csv")
- row = top.iloc[0]
- base = rotation_risk.params_from_row(row)
- params = rotation_risk.RiskParams(
- base=base,
- leverage=float(row["leverage"]),
- exposure=float(row["exposure"]),
- vol_target=float(row["vol_target"]),
- )
- frames = rotation.load_symbol_bar_frames(years)
- closes = rotation.aligned_closes(frames, base)
- weights = rotation_risk.apply_risk_controls(closes, rotation.target_weights(closes, base), params)
- return str(row["strategy"]), rotation_risk.equity_curve(closes, weights, params)
- def combined_equity(rotation_equity: pd.Series, short_equity: pd.Series, short_alloc: float) -> pd.Series:
- returns = pd.DataFrame(
- {
- "rotation": rotation_equity.pct_change().fillna(0.0),
- "short": short_equity.pct_change().fillna(0.0),
- }
- ).dropna()
- combo_returns = (1.0 - short_alloc) * returns["rotation"] + short_alloc * returns["short"]
- equity = INITIAL_EQUITY * (1.0 + combo_returns).cumprod()
- equity.name = "equity"
- return equity
- def format_cell(value: object) -> str:
- if isinstance(value, float):
- return f"{value:.6g}"
- return str(value).replace("|", "\\|")
- def markdown_table(frame: pd.DataFrame) -> str:
- rows = [list(frame.columns), ["---" for _ in frame.columns]]
- rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
- return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
- def report_text(
- command: str,
- paths: list[Path],
- totals: pd.DataFrame,
- horizons: pd.DataFrame,
- monthly: pd.DataFrame,
- correlations: pd.DataFrame,
- worst: pd.DataFrame,
- ) -> str:
- selected = totals[totals["selected"] == "yes"].copy()
- best_short = selected[selected["kind"] == "short_leg"].head(1)
- best_combo = selected[selected["kind"] == "rotation_plus_short"].head(4)
- conclusion = "Not worth adding."
- if len(best_combo):
- row = best_combo.sort_values(["max_drawdown", "calmar"], ascending=[True, False]).iloc[0]
- conclusion = (
- "Worth only as a small protection leg"
- if float(row["max_drawdown_delta_vs_rotation"]) < 0.0 and float(row["annualized_return_delta_vs_rotation"]) > -0.03
- else "Not worth adding at the tested small weights"
- )
- focus_names = set(selected["name"])
- return "\n".join(
- [
- "# Short Bias Overlay",
- "",
- f"Run command: `{command}`",
- "",
- "Output files:",
- *[f"- `{path}`" for path in paths],
- "",
- "Scope: BTC/ETH short-only risk-state overlays from cached candles. Cost model is 0.04% one-way taker fee on absolute notional turnover.",
- "Combination test uses rotation-risk as the base and allocates 5%, 10%, or 15% of capital to the short leg.",
- "",
- f"Conclusion: {conclusion}.",
- "",
- "## Selected short leg",
- "",
- markdown_table(best_short),
- "",
- "## Selected rotation-risk combinations",
- "",
- markdown_table(best_combo),
- "",
- "## Horizons",
- "",
- markdown_table(horizons[horizons["name"].isin(focus_names)]),
- "",
- "## Monthly returns",
- "",
- markdown_table(monthly[monthly["name"].isin(focus_names)].tail(80)),
- "",
- "## Correlation",
- "",
- markdown_table(correlations),
- "",
- "## Worst rolling 6/12m",
- "",
- markdown_table(worst[worst["name"].isin(focus_names)]),
- "",
- ]
- )
- def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument("--years", type=float, default=8.0)
- parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
- parser.add_argument("--top", type=int, default=20)
- args = parser.parse_args()
- rotation_name, rotation_equity_raw = rotation_risk_equity(args.years)
- frames = rotation.load_symbol_bar_frames(args.years)
- short_results: list[dict[str, object]] = []
- short_equities: dict[str, pd.Series] = {}
- short_weights_by_name: dict[str, pd.DataFrame] = {}
- for param in build_short_params():
- closes = pd.DataFrame({symbol: frames[(symbol, param.bar)]["close"] for symbol in SYMBOLS}).dropna()
- weights = short_weights(closes, param)
- equity = equity_from_weights(closes, weights)
- short_equities[param.name] = equity
- short_weights_by_name[param.name] = weights
- horizon = {row["horizon"]: row for row in horizon_rows(param.name, "short_leg", equity)}
- years = (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365
- short_results.append(
- {
- "name": param.name,
- "kind": "short_leg",
- "family": param.family,
- "bar": param.bar,
- "allocation": 1.0,
- "years": years,
- "h3y_return": horizon["3y"]["total_return"],
- "h1y_return": horizon["1y"]["total_return"],
- "h6m_return": horizon["6m"]["total_return"],
- "h3m_return": horizon["3m"]["total_return"],
- "turnover_per_year": float(weights.diff().abs().sum(axis=1).sum() / years),
- **metrics(equity),
- **trade_stats(weights, closes),
- **param.__dict__,
- }
- )
- rotation_monthly = monthly_rows(rotation_name, "rotation_risk", rotation_equity_raw)
- rotation_total = {
- "name": rotation_name,
- "kind": "rotation_risk",
- "family": "rotation_risk",
- "bar": "",
- "allocation": 1.0,
- "years": (rotation_equity_raw.index[-1] - rotation_equity_raw.index[0]).total_seconds() / 86_400 / 365,
- **horizon_return_fields(rotation_equity_raw),
- **metrics(rotation_equity_raw),
- }
- rotation_metrics = metrics(rotation_equity_raw)
- ranked_short = pd.DataFrame(short_results).sort_values(
- ["h1y_return", "h6m_return", "h3m_return", "calmar", "total_return"],
- ascending=[False, False, False, False, False],
- )
- ranked_unique = ranked_short.drop_duplicates(
- subset=["total_return", "h3y_return", "h1y_return", "h6m_return", "h3m_return", "max_drawdown"]
- )
- candidates = ranked_unique.head(args.top)
- totals: list[dict[str, object]] = [rotation_total]
- totals.extend(short_results)
- horizon_output = horizon_rows(rotation_name, "rotation_risk", rotation_equity_raw)
- monthly_frames = [rotation_monthly]
- worst_rows = worst_rolling_rows(rotation_name, "rotation_risk", rotation_monthly)
- correlations: list[dict[str, object]] = []
- rotation_daily = rotation_equity_raw.resample("1D").last().ffill()
- for _, short_row in candidates.iterrows():
- short_name = str(short_row["name"])
- short_daily = short_equities[short_name].resample("1D").last().ffill()
- aligned = pd.concat([rotation_daily, short_daily], axis=1, join="inner")
- aligned.columns = ["rotation", "short"]
- correlations.append(
- {
- "name": short_name,
- "kind": "short_leg",
- "correlation_to_rotation_risk": float(aligned.pct_change().dropna().corr().loc["rotation", "short"]),
- }
- )
- horizon_output.extend(horizon_rows(short_name, "short_leg", short_daily))
- short_monthly = monthly_rows(short_name, "short_leg", short_daily)
- monthly_frames.append(short_monthly)
- worst_rows.extend(worst_rolling_rows(short_name, "short_leg", short_monthly))
- for allocation in (0.05, 0.10, 0.15):
- combo_name = f"{rotation_name}+{allocation:.0%}-{short_name}"
- combo = combined_equity(rotation_daily, short_daily, allocation)
- combo_metrics = metrics(combo)
- combo_monthly = monthly_rows(combo_name, "rotation_plus_short", combo)
- totals.append(
- {
- "name": combo_name,
- "kind": "rotation_plus_short",
- "family": str(short_row["family"]),
- "bar": str(short_row["bar"]),
- "allocation": allocation,
- "years": (combo.index[-1] - combo.index[0]).total_seconds() / 86_400 / 365,
- "short_leg": short_name,
- "annualized_return_delta_vs_rotation": combo_metrics["annualized_return"] - rotation_metrics["annualized_return"],
- "max_drawdown_delta_vs_rotation": combo_metrics["max_drawdown"] - rotation_metrics["max_drawdown"],
- **horizon_return_fields(combo),
- **combo_metrics,
- }
- )
- horizon_output.extend(horizon_rows(combo_name, "rotation_plus_short", combo))
- monthly_frames.append(combo_monthly)
- worst_rows.extend(worst_rolling_rows(combo_name, "rotation_plus_short", combo_monthly))
- aligned_combo = pd.concat([rotation_daily, combo], axis=1, join="inner")
- aligned_combo.columns = ["rotation", "combo"]
- correlations.append(
- {
- "name": combo_name,
- "kind": "rotation_plus_short",
- "correlation_to_rotation_risk": float(aligned_combo.pct_change().dropna().corr().loc["rotation", "combo"]),
- }
- )
- total = pd.DataFrame(totals)
- combos = total[total["kind"] == "rotation_plus_short"].copy()
- selected_combo = combos[
- (combos["max_drawdown_delta_vs_rotation"] < 0.0)
- & (combos["annualized_return_delta_vs_rotation"] > -0.03)
- ].sort_values(["max_drawdown", "calmar"], ascending=[True, False]).head(4)
- selected_short_names = set(selected_combo["short_leg"]) if len(selected_combo) else set(candidates.head(1)["name"])
- total["selected"] = "no"
- total.loc[total["name"].isin(selected_short_names), "selected"] = "yes"
- total.loc[total["name"].isin(set(selected_combo["name"])), "selected"] = "yes"
- total.loc[total["name"] == rotation_name, "selected"] = "yes"
- horizons = pd.DataFrame(horizon_output)
- monthly = pd.concat(monthly_frames, ignore_index=True)
- correlation_frame = pd.DataFrame(correlations)
- worst = pd.DataFrame(worst_rows)
- args.output_dir.mkdir(parents=True, exist_ok=True)
- total_path = args.output_dir / f"{PREFIX}-total.csv"
- short_path = args.output_dir / f"{PREFIX}-short-leg.csv"
- combo_path = args.output_dir / f"{PREFIX}-rotation-combo.csv"
- horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
- monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
- correlation_path = args.output_dir / f"{PREFIX}-correlation.csv"
- worst_path = args.output_dir / f"{PREFIX}-worst-rolling.csv"
- report_path = args.output_dir / f"{PREFIX}-report.md"
- paths = [total_path, short_path, combo_path, horizon_path, monthly_path, correlation_path, worst_path, report_path]
- total.to_csv(total_path, index=False)
- ranked_short.to_csv(short_path, index=False)
- combos.sort_values(["max_drawdown", "calmar"], ascending=[True, False]).to_csv(combo_path, index=False)
- horizons.to_csv(horizon_path, index=False)
- monthly.to_csv(monthly_path, index=False)
- correlation_frame.to_csv(correlation_path, index=False)
- worst.to_csv(worst_path, index=False)
- command = "rtk .venv/bin/python " + " ".join(sys.argv)
- report_path.write_text(report_text(command, paths, total, horizons, monthly, correlation_frame, worst), encoding="utf-8")
- print(total[total["selected"] == "yes"].to_string(index=False))
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|