search_short_overlay_mix.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. from __future__ import annotations
  2. import argparse
  3. import sys
  4. from dataclasses import dataclass
  5. from itertools import product
  6. from pathlib import Path
  7. import pandas as pd
  8. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  9. from scripts import refine_expansion_rotation_risk as rotation_risk
  10. from scripts import search_expansion_rotation as rotation
  11. from scripts.search_short_bias_overlay import markdown_table
  12. OUTPUT_DIR = Path("reports/short-bias")
  13. PREFIX = "overlay-mix"
  14. INITIAL_EQUITY = 10_000.0
  15. TAKER_FEE = 0.0004
  16. SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP", "SOL-USDT-SWAP")
  17. SHORTABLE = ("ETH-USDT-SWAP", "SOL-USDT-SWAP")
  18. HORIZONS = (
  19. ("full", None),
  20. ("3y", pd.DateOffset(years=3)),
  21. ("1y", pd.DateOffset(years=1)),
  22. ("6m", pd.DateOffset(months=6)),
  23. ("3m", pd.DateOffset(months=3)),
  24. )
  25. @dataclass(frozen=True)
  26. class BtcRiskShort:
  27. family: str
  28. bar: str
  29. btc_trend: int
  30. btc_lookback: int
  31. symbol_trend: int
  32. vol_lookback: int
  33. btc_max_momentum: float
  34. btc_min_drop: float
  35. min_btc_vol: float
  36. symbol_max_momentum: float
  37. short_symbols: tuple[str, ...]
  38. @property
  39. def name(self) -> str:
  40. suffix = "ethsol" if self.short_symbols == SHORTABLE else "eth"
  41. return (
  42. f"{self.family}-{self.bar}-{suffix}-bt{self.btc_trend}"
  43. f"-bl{self.btc_lookback}-st{self.symbol_trend}-vw{self.vol_lookback}"
  44. f"-bm{self.btc_max_momentum:.3f}-bd{self.btc_min_drop:.3f}"
  45. f"-bv{self.min_btc_vol:.4f}-sm{self.symbol_max_momentum:.3f}"
  46. )
  47. def build_short_params() -> list[BtcRiskShort]:
  48. params: list[BtcRiskShort] = []
  49. for bar in ("1h", "4h"):
  50. if bar == "1h":
  51. btc_trends = (24 * 60, 24 * 120)
  52. btc_lookbacks = (24 * 7, 24 * 14)
  53. symbol_trends = (24 * 30, 24 * 60)
  54. vol_lookbacks = (24 * 14,)
  55. min_vols = (0.012, 0.018, 0.024)
  56. drops = (0.025, 0.040, 0.060)
  57. else:
  58. btc_trends = (6 * 60, 6 * 120)
  59. btc_lookbacks = (6 * 7, 6 * 14)
  60. symbol_trends = (6 * 30, 6 * 60)
  61. vol_lookbacks = (6 * 14,)
  62. min_vols = (0.020, 0.030, 0.040)
  63. drops = (0.030, 0.050, 0.070)
  64. for family, btc_trend, btc_lookback, symbol_trend, vol_lookback, btc_max_momentum, drop, min_vol, symbol_max_momentum, short_symbols in product(
  65. ("btc_risk_pair", "btc_breakdown_symbol"),
  66. btc_trends,
  67. btc_lookbacks,
  68. symbol_trends,
  69. vol_lookbacks,
  70. (-0.005, 0.000),
  71. drops,
  72. min_vols,
  73. (-0.010, 0.000, 0.020),
  74. (("ETH-USDT-SWAP",), SHORTABLE),
  75. ):
  76. params.append(
  77. BtcRiskShort(
  78. family=family,
  79. bar=bar,
  80. btc_trend=btc_trend,
  81. btc_lookback=btc_lookback,
  82. symbol_trend=symbol_trend,
  83. vol_lookback=vol_lookback,
  84. btc_max_momentum=btc_max_momentum,
  85. btc_min_drop=drop,
  86. min_btc_vol=min_vol,
  87. symbol_max_momentum=symbol_max_momentum,
  88. short_symbols=short_symbols,
  89. )
  90. )
  91. return params
  92. def metrics(series: pd.Series) -> dict[str, float]:
  93. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  94. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  95. annualized = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  96. drawdown = float((series.cummax() - series).div(series.cummax()).max())
  97. return {
  98. "total_return": total,
  99. "annualized_return": annualized,
  100. "max_drawdown": drawdown,
  101. "calmar": annualized / drawdown if drawdown else 0.0,
  102. }
  103. def horizon_rows(name: str, kind: str, series: pd.Series) -> list[dict[str, object]]:
  104. rows: list[dict[str, object]] = []
  105. end = series.index[-1]
  106. for label, offset in HORIZONS:
  107. horizon = series if offset is None else series[series.index >= end - offset]
  108. if len(horizon) < 2:
  109. horizon = series
  110. rows.append(
  111. {
  112. "name": name,
  113. "kind": kind,
  114. "horizon": label,
  115. "start": horizon.index[0].strftime("%Y-%m-%d"),
  116. "end": horizon.index[-1].strftime("%Y-%m-%d"),
  117. **metrics(horizon),
  118. }
  119. )
  120. return rows
  121. def horizon_return_fields(series: pd.Series) -> dict[str, float]:
  122. rows = {row["horizon"]: row for row in horizon_rows("", "", series)}
  123. return {
  124. "h3y_return": float(rows["3y"]["total_return"]),
  125. "h1y_return": float(rows["1y"]["total_return"]),
  126. "h6m_return": float(rows["6m"]["total_return"]),
  127. "h3m_return": float(rows["3m"]["total_return"]),
  128. }
  129. def daily(series: pd.Series) -> pd.Series:
  130. index = pd.date_range(series.index[0].normalize(), series.index[-1].normalize(), freq="1D", tz="UTC")
  131. return series.resample("1D").last().reindex(index).ffill()
  132. def monthly_rows(name: str, kind: str, series: pd.Series) -> pd.DataFrame:
  133. monthly = series.resample("ME").last()
  134. frame = pd.DataFrame(
  135. {
  136. "name": name,
  137. "kind": kind,
  138. "month": monthly.index.strftime("%Y-%m"),
  139. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  140. "end_equity": monthly.to_numpy(),
  141. }
  142. )
  143. frame["return"] = frame["end_equity"] / frame["start_equity"] - 1.0
  144. return frame
  145. def worst_rolling_rows(name: str, kind: str, monthly: pd.DataFrame) -> list[dict[str, object]]:
  146. rows: list[dict[str, object]] = []
  147. returns = monthly.set_index("month")["return"]
  148. for window in (6, 12):
  149. rolled = (1.0 + returns).rolling(window).apply(lambda values: float(values.prod() - 1.0), raw=True)
  150. rolled = rolled.dropna()
  151. if rolled.empty:
  152. continue
  153. end_month = rolled.idxmin()
  154. rows.append(
  155. {
  156. "name": name,
  157. "kind": kind,
  158. "window_months": window,
  159. "end_month": end_month,
  160. "return": float(rolled.loc[end_month]),
  161. }
  162. )
  163. return rows
  164. def rotation_base(years: float) -> tuple[str, pd.Series, dict[str, float | int]]:
  165. row = pd.read_csv(rotation_risk.OUTPUT_DIR / "rotation-risk-top.csv").iloc[0]
  166. base = rotation_risk.params_from_row(row)
  167. params = rotation_risk.RiskParams(
  168. base=base,
  169. leverage=float(row["leverage"]),
  170. exposure=float(row["exposure"]),
  171. vol_target=float(row["vol_target"]),
  172. )
  173. rotation.SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  174. frames = rotation.load_symbol_bar_frames(years)
  175. closes = rotation.aligned_closes(frames, base)
  176. weights = rotation_risk.apply_risk_controls(closes, rotation.target_weights(closes, base), params)
  177. equity = rotation_risk.equity_curve(closes, weights, params)
  178. stats = rotation_risk.trade_stats(weights, closes, params.leverage)
  179. return str(row["strategy"]), equity, stats
  180. def load_frames(years: float) -> dict[tuple[str, str], pd.DataFrame]:
  181. rotation.SYMBOLS = SYMBOLS
  182. return rotation.load_symbol_bar_frames(years)
  183. def short_weights(closes: pd.DataFrame, params: BtcRiskShort) -> pd.DataFrame:
  184. returns = closes.pct_change()
  185. btc = closes["BTC-USDT-SWAP"]
  186. btc_trend = btc.rolling(params.btc_trend).mean()
  187. btc_momentum = btc / btc.shift(params.btc_lookback) - 1.0
  188. btc_vol = returns["BTC-USDT-SWAP"].rolling(params.vol_lookback).std(ddof=1) * (365 * {"1h": 24, "4h": 6}[params.bar]) ** 0.5
  189. risk_state = (
  190. (btc < btc_trend)
  191. & (btc_momentum <= params.btc_max_momentum)
  192. & (btc_momentum <= -params.btc_min_drop)
  193. & (btc_vol >= params.min_btc_vol)
  194. )
  195. symbol_trend = closes.rolling(params.symbol_trend).mean()
  196. symbol_momentum = closes / closes.shift(params.btc_lookback) - 1.0
  197. eligible = (closes < symbol_trend) & (symbol_momentum <= params.symbol_max_momentum)
  198. weights = pd.DataFrame(0.0, index=closes.index, columns=closes.columns)
  199. if params.family == "btc_risk_pair":
  200. active_symbols = [symbol for symbol in params.short_symbols if symbol in closes.columns]
  201. if active_symbols:
  202. weights.loc[risk_state, active_symbols] = -1.0 / len(active_symbols)
  203. elif params.family == "btc_breakdown_symbol":
  204. active_symbols = [symbol for symbol in params.short_symbols if symbol in closes.columns]
  205. active = eligible[active_symbols].where(risk_state, False)
  206. counts = active.sum(axis=1)
  207. weights.loc[:, active_symbols] = active.astype(float).div(counts.where(counts > 0.0), axis=0).fillna(0.0) * -1.0
  208. else:
  209. raise ValueError(f"unknown family {params.family}")
  210. return weights.fillna(0.0)
  211. def equity_from_weights(closes: pd.DataFrame, weights: pd.DataFrame) -> pd.Series:
  212. returns = closes.pct_change().fillna(0.0)
  213. executed = weights.shift(1).fillna(0.0)
  214. turnover = executed.diff().abs().sum(axis=1).fillna(executed.abs().sum(axis=1))
  215. net_returns = (executed * returns).sum(axis=1) - turnover * TAKER_FEE
  216. equity = INITIAL_EQUITY * (1.0 + net_returns).cumprod()
  217. equity.name = "equity"
  218. return equity
  219. def trade_stats(weights: pd.DataFrame, closes: pd.DataFrame) -> dict[str, float | int]:
  220. executed = weights.shift(1).fillna(0.0)
  221. returns = closes.pct_change().fillna(0.0)
  222. turnover = executed.diff().abs().fillna(executed.abs())
  223. wins = 0
  224. trades = 0
  225. gross_profit = 0.0
  226. gross_loss = 0.0
  227. for symbol in SHORTABLE:
  228. if symbol not in closes.columns:
  229. continue
  230. active = executed[symbol] < 0.0
  231. groups = (active != active.shift(1)).cumsum()
  232. for _, mask in active.groupby(groups):
  233. if not bool(mask.iloc[0]):
  234. continue
  235. index = mask.index
  236. net = returns.loc[index, symbol] * executed.loc[index, symbol] - turnover.loc[index, symbol] * TAKER_FEE
  237. value = float((1.0 + net).prod() - 1.0)
  238. trades += 1
  239. if value > 0.0:
  240. wins += 1
  241. gross_profit += value
  242. else:
  243. gross_loss += abs(value)
  244. return {
  245. "trades": trades,
  246. "win_rate": wins / trades if trades else 0.0,
  247. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  248. }
  249. def combine(base: pd.Series, short: pd.Series, allocation: float) -> pd.Series:
  250. returns = pd.DataFrame({"base": base.pct_change().fillna(0.0), "short": short.pct_change().fillna(0.0)}).dropna()
  251. combined = (1.0 - allocation) * returns["base"] + allocation * returns["short"]
  252. equity = INITIAL_EQUITY * (1.0 + combined).cumprod()
  253. equity.name = "equity"
  254. return equity
  255. def recent_not_worse(combo: pd.Series, base: pd.Series) -> bool:
  256. combo_returns = horizon_return_fields(combo)
  257. base_returns = horizon_return_fields(base)
  258. return all(combo_returns[key] >= base_returns[key] for key in ("h1y_return", "h6m_return", "h3m_return"))
  259. def report_text(
  260. command: str,
  261. paths: list[Path],
  262. totals: pd.DataFrame,
  263. horizons: pd.DataFrame,
  264. monthly: pd.DataFrame,
  265. worst: pd.DataFrame,
  266. signal_defs: pd.DataFrame,
  267. ) -> str:
  268. selected = totals[totals["selected"] == "yes"]
  269. server = selected[selected["server_readonly_candidate"] == "yes"]
  270. return "\n".join(
  271. [
  272. "# Short Overlay Mix",
  273. "",
  274. f"Run command: `{command}`",
  275. "",
  276. "Output files:",
  277. *[f"- `{path}`" for path in paths],
  278. "",
  279. "Objective: add a small short overlay to the existing rotation long strategy and keep the recent 1y/6m/3m total returns no worse than the rotation base while reducing max drawdown.",
  280. "Cost model: 0.04% one-way taker fee on absolute short-overlay notional turnover. Rotation-risk keeps its existing cost model.",
  281. "",
  282. "## Result",
  283. "",
  284. markdown_table(selected),
  285. "",
  286. "## Server read-only signal candidates",
  287. "",
  288. markdown_table(server),
  289. "",
  290. "## Signal definitions",
  291. "",
  292. markdown_table(signal_defs),
  293. "",
  294. "## Horizons",
  295. "",
  296. markdown_table(horizons[horizons["name"].isin(set(selected["name"]))]),
  297. "",
  298. "## Worst rolling 6/12m",
  299. "",
  300. markdown_table(worst[worst["name"].isin(set(selected["name"]))]),
  301. "",
  302. "## Recent monthly returns",
  303. "",
  304. markdown_table(monthly[monthly["name"].isin(set(selected["name"]))].tail(96)),
  305. "",
  306. ]
  307. )
  308. def main() -> int:
  309. parser = argparse.ArgumentParser()
  310. parser.add_argument("--years", type=float, default=8.0)
  311. parser.add_argument("--top", type=int, default=20)
  312. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  313. args = parser.parse_args()
  314. base_name, base_raw, base_stats = rotation_base(args.years)
  315. base_daily = daily(base_raw)
  316. frames = load_frames(args.years)
  317. totals: list[dict[str, object]] = [
  318. {
  319. "name": base_name,
  320. "kind": "base_rotation",
  321. "base": "",
  322. "short_leg": "",
  323. "allocation": 0.0,
  324. "server_readonly_candidate": "no",
  325. "selected": "yes",
  326. "years": (base_daily.index[-1] - base_daily.index[0]).total_seconds() / 86_400 / 365,
  327. **horizon_return_fields(base_daily),
  328. **metrics(base_daily),
  329. **base_stats,
  330. }
  331. ]
  332. horizon_output = horizon_rows(base_name, "base_rotation", base_daily)
  333. monthly_frames = [monthly_rows(base_name, "base_rotation", base_daily)]
  334. worst_rows = worst_rolling_rows(base_name, "base_rotation", monthly_frames[0])
  335. short_rows: list[dict[str, object]] = []
  336. short_equities: dict[str, pd.Series] = {}
  337. short_stats: dict[str, dict[str, float | int]] = {}
  338. params_by_name: dict[str, BtcRiskShort] = {}
  339. signal_defs: list[dict[str, object]] = []
  340. for param in build_short_params():
  341. params_by_name[param.name] = param
  342. required_symbols = ("BTC-USDT-SWAP", *param.short_symbols)
  343. closes = pd.DataFrame({symbol: frames[(symbol, param.bar)]["close"] for symbol in required_symbols}).dropna()
  344. weights = short_weights(closes, param)
  345. equity = equity_from_weights(closes, weights)
  346. equity_daily = daily(equity)
  347. short_equities[param.name] = equity_daily
  348. horizon = horizon_return_fields(equity_daily)
  349. years = (equity.index[-1] - equity.index[0]).total_seconds() / 86_400 / 365
  350. active_days = int((weights[list(set(param.short_symbols) & set(weights.columns))].abs().sum(axis=1) > 0.0).sum())
  351. short_rows.append(
  352. {
  353. "name": param.name,
  354. "kind": "short_leg",
  355. "family": param.family,
  356. "base": "",
  357. "short_leg": "",
  358. "allocation": 1.0,
  359. "bar": param.bar,
  360. "short_symbols": ",".join(param.short_symbols),
  361. "server_readonly_candidate": "no",
  362. "selected": "no",
  363. "years": years,
  364. "turnover_per_year": float(weights.diff().abs().sum(axis=1).sum() / years),
  365. "active_bars": active_days,
  366. **horizon,
  367. **metrics(equity_daily),
  368. "trades": 0,
  369. "win_rate": 0.0,
  370. "profit_factor": 0.0,
  371. }
  372. )
  373. signal_defs.append(
  374. {
  375. "short_leg": param.name,
  376. "definition": (
  377. f"On {param.bar}, BTC close below SMA({param.btc_trend}), "
  378. f"BTC {param.btc_lookback}-bar momentum <= {param.btc_max_momentum:.3f} "
  379. f"and <= -{param.btc_min_drop:.3f}, annualized BTC vol({param.vol_lookback}) >= {param.min_btc_vol:.4f}; "
  380. f"short {','.join(param.short_symbols)}"
  381. + (
  382. f" only when each symbol is below SMA({param.symbol_trend}) and momentum <= {param.symbol_max_momentum:.3f}."
  383. if param.family == "btc_breakdown_symbol"
  384. else "."
  385. )
  386. ),
  387. }
  388. )
  389. short_frame = pd.DataFrame(short_rows).sort_values(
  390. ["h1y_return", "h6m_return", "h3m_return", "calmar"],
  391. ascending=[False, False, False, False],
  392. )
  393. candidates = short_frame.head(args.top)
  394. totals.extend(short_rows)
  395. for _, short_row in candidates.iterrows():
  396. short_name = str(short_row["name"])
  397. param = params_by_name[short_name]
  398. required_symbols = ("BTC-USDT-SWAP", *param.short_symbols)
  399. closes = pd.DataFrame({symbol: frames[(symbol, param.bar)]["close"] for symbol in required_symbols}).dropna()
  400. stats = trade_stats(short_weights(closes, param), closes)
  401. short_stats[short_name] = stats
  402. short = short_equities[short_name]
  403. horizon_output.extend(horizon_rows(short_name, "short_leg", short))
  404. short_monthly = monthly_rows(short_name, "short_leg", short)
  405. monthly_frames.append(short_monthly)
  406. worst_rows.extend(worst_rolling_rows(short_name, "short_leg", short_monthly))
  407. for allocation in (0.03, 0.05, 0.07, 0.10):
  408. combo_name = f"{base_name}+{allocation:.0%}-{short_name}"
  409. combo = combine(base_daily, short, allocation)
  410. combo_metrics = metrics(combo)
  411. base_metrics = metrics(base_daily.loc[combo.index])
  412. qualifies = combo_metrics["max_drawdown"] < base_metrics["max_drawdown"] and recent_not_worse(combo, base_daily.loc[combo.index])
  413. stats = short_stats[short_name]
  414. totals.append(
  415. {
  416. "name": combo_name,
  417. "kind": "rotation_plus_btc_risk_short",
  418. "family": str(short_row["family"]),
  419. "base": base_name,
  420. "short_leg": short_name,
  421. "allocation": allocation,
  422. "bar": str(short_row["bar"]),
  423. "short_symbols": str(short_row["short_symbols"]),
  424. "server_readonly_candidate": "yes" if qualifies else "no",
  425. "selected": "yes" if qualifies else "no",
  426. "years": (combo.index[-1] - combo.index[0]).total_seconds() / 86_400 / 365,
  427. "annualized_return_delta_vs_base": combo_metrics["annualized_return"] - base_metrics["annualized_return"],
  428. "max_drawdown_delta_vs_base": combo_metrics["max_drawdown"] - base_metrics["max_drawdown"],
  429. **horizon_return_fields(combo),
  430. **combo_metrics,
  431. "trades": int(base_stats["trades"]) + int(stats["trades"]),
  432. "win_rate": stats["win_rate"],
  433. "profit_factor": stats["profit_factor"],
  434. "overlay_trades": stats["trades"],
  435. "overlay_win_rate": stats["win_rate"],
  436. "overlay_profit_factor": stats["profit_factor"],
  437. }
  438. )
  439. horizon_output.extend(horizon_rows(combo_name, "rotation_plus_btc_risk_short", combo))
  440. combo_monthly = monthly_rows(combo_name, "rotation_plus_btc_risk_short", combo)
  441. monthly_frames.append(combo_monthly)
  442. worst_rows.extend(worst_rolling_rows(combo_name, "rotation_plus_btc_risk_short", combo_monthly))
  443. total = pd.DataFrame(totals)
  444. combos = total[total["kind"] == "rotation_plus_btc_risk_short"].copy()
  445. total.loc[total["kind"] != "base_rotation", "selected"] = "no"
  446. qualified = combos[combos["server_readonly_candidate"] == "yes"].sort_values(
  447. ["max_drawdown", "calmar", "h1y_return", "h6m_return", "h3m_return"],
  448. ascending=[True, False, False, False, False],
  449. )
  450. selected_combos = qualified.drop_duplicates(
  451. subset=[
  452. "allocation",
  453. "family",
  454. "bar",
  455. "short_symbols",
  456. "h1y_return",
  457. "h6m_return",
  458. "h3m_return",
  459. "max_drawdown",
  460. ]
  461. ).head(5)
  462. if selected_combos.empty:
  463. selected_combos = combos.sort_values(
  464. ["max_drawdown", "h1y_return", "h6m_return", "h3m_return"],
  465. ascending=[True, False, False, False],
  466. ).head(3)
  467. total.loc[total["name"].isin(set(selected_combos["name"])), "selected"] = "yes"
  468. selected_short = set(total.loc[total["selected"] == "yes", "short_leg"]) - {""}
  469. total.loc[total["name"].isin(selected_short), "selected"] = "yes"
  470. for short_name in selected_short:
  471. if short_name in short_stats:
  472. for key, value in short_stats[short_name].items():
  473. total.loc[total["name"] == short_name, key] = value
  474. horizons = pd.DataFrame(horizon_output)
  475. monthly = pd.concat(monthly_frames, ignore_index=True)
  476. worst = pd.DataFrame(worst_rows)
  477. signal_defs_frame = pd.DataFrame(signal_defs)
  478. signal_defs_frame = signal_defs_frame[signal_defs_frame["short_leg"].isin(selected_short)]
  479. args.output_dir.mkdir(parents=True, exist_ok=True)
  480. total_path = args.output_dir / f"{PREFIX}-total.csv"
  481. short_path = args.output_dir / f"{PREFIX}-short-leg.csv"
  482. combo_path = args.output_dir / f"{PREFIX}-combo.csv"
  483. horizon_path = args.output_dir / f"{PREFIX}-horizons.csv"
  484. monthly_path = args.output_dir / f"{PREFIX}-monthly.csv"
  485. worst_path = args.output_dir / f"{PREFIX}-worst-rolling.csv"
  486. signal_path = args.output_dir / f"{PREFIX}-signal-definitions.csv"
  487. report_path = args.output_dir / f"{PREFIX}-report.md"
  488. paths = [total_path, short_path, combo_path, horizon_path, monthly_path, worst_path, signal_path, report_path]
  489. total.to_csv(total_path, index=False)
  490. short_frame.to_csv(short_path, index=False)
  491. combos.sort_values(["server_readonly_candidate", "max_drawdown", "h1y_return"], ascending=[False, True, False]).to_csv(combo_path, index=False)
  492. horizons.to_csv(horizon_path, index=False)
  493. monthly.to_csv(monthly_path, index=False)
  494. worst.to_csv(worst_path, index=False)
  495. signal_defs_frame.to_csv(signal_path, index=False)
  496. command = "rtk .venv/bin/python " + " ".join(sys.argv)
  497. report_path.write_text(report_text(command, paths, total, horizons, monthly, worst, signal_defs_frame), encoding="utf-8")
  498. print(total[total["selected"] == "yes"].to_string(index=False))
  499. return 0
  500. if __name__ == "__main__":
  501. raise SystemExit(main())