search_short_bias_regime_alt.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. from dataclasses import dataclass
  5. from itertools import product
  6. from pathlib import Path
  7. import pandas as pd
  8. DATA_DIR = Path("data/okx-candles")
  9. OUTPUT_DIR = Path("reports/short-bias")
  10. PREFIX = "regime-alt"
  11. SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  12. BARS = ("4H", "1D")
  13. INITIAL_EQUITY = 10_000.0
  14. TAKER_FEE = 0.0004
  15. ROUNDTRIP_FEE = TAKER_FEE * 2
  16. YEARS = 10.0
  17. HORIZONS = (
  18. ("3y", pd.DateOffset(years=3)),
  19. ("1y", pd.DateOffset(years=1)),
  20. ("6m", pd.DateOffset(months=6)),
  21. ("3m", pd.DateOffset(months=3)),
  22. )
  23. @dataclass(frozen=True)
  24. class Strategy:
  25. family: str
  26. symbol: str
  27. bar: str
  28. params: dict[str, float | int]
  29. @property
  30. def name(self) -> str:
  31. params = "-".join(f"{key}{value}" for key, value in self.params.items())
  32. return f"{self.family}-{self.symbol.split('-')[0].lower()}-{self.bar}-{params}"
  33. def load_15m_frame(symbol: str, years: float) -> pd.DataFrame:
  34. path = DATA_DIR / symbol / "15m.csv"
  35. if not path.exists():
  36. raise FileNotFoundError(f"missing local cache: {path}")
  37. frame = pd.read_csv(path)
  38. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  39. frame = frame.sort_values("ts").drop_duplicates("ts", keep="last").set_index("ts")
  40. start = frame.index[-1] - pd.DateOffset(years=years)
  41. return frame[frame.index >= start]
  42. def resample_frame(frame: pd.DataFrame, bar: str) -> pd.DataFrame:
  43. rule = {"4H": "4h", "1D": "1D"}[bar]
  44. out = frame.resample(rule, label="left", closed="left").agg(
  45. open=("open", "first"),
  46. high=("high", "max"),
  47. low=("low", "min"),
  48. close=("close", "last"),
  49. volume=("volume", "sum"),
  50. )
  51. return out.dropna()
  52. def true_range(frame: pd.DataFrame) -> pd.Series:
  53. previous = frame["close"].shift(1)
  54. return pd.concat(
  55. [
  56. frame["high"] - frame["low"],
  57. (frame["high"] - previous).abs(),
  58. (frame["low"] - previous).abs(),
  59. ],
  60. axis=1,
  61. ).max(axis=1)
  62. def signal_frame(strategy: Strategy, frame: pd.DataFrame, btc_frame: pd.DataFrame | None) -> pd.DataFrame:
  63. close = frame["close"]
  64. high = frame["high"]
  65. low = frame["low"]
  66. p = strategy.params
  67. fast = close.ewm(span=int(p["fast"]), adjust=False).mean()
  68. slow = close.ewm(span=int(p["slow"]), adjust=False).mean()
  69. atr = true_range(frame).rolling(int(p["atr"])).mean()
  70. atr_pct = atr / close
  71. prior_low = low.shift(1).rolling(int(p["break_lookback"])).min()
  72. prior_high = high.shift(1).rolling(int(p["exit_lookback"])).max()
  73. broken_recently = (close < prior_low).rolling(int(p["retest_window"])).max().fillna(0.0).astype(bool)
  74. if strategy.family == "trend_break_retest_short":
  75. entry = broken_recently & (close < slow) & (high >= fast) & (close < fast)
  76. exit_ = (close > fast) | (close > prior_high)
  77. elif strategy.family == "vol_expansion_rebound_fail_short":
  78. vol_gate = atr_pct > atr_pct.rolling(int(p["vol_window"])).quantile(float(p["vol_quantile"]))
  79. failed_rebound = (high >= fast) & (close < fast) & (close < slow)
  80. entry = broken_recently & vol_gate & failed_rebound
  81. exit_ = (close > fast) | (atr_pct < atr_pct.rolling(int(p["vol_window"])).median())
  82. elif strategy.family == "btc_weakness_transmission_short":
  83. if btc_frame is None:
  84. raise ValueError("btc_weakness_transmission_short requires BTC frame")
  85. aligned = pd.DataFrame({"asset_close": close, "asset_high": high}).join(
  86. btc_frame[["close"]].rename(columns={"close": "btc_close"}), how="inner"
  87. )
  88. btc_close = aligned["btc_close"]
  89. btc_slow = btc_close.ewm(span=int(p["btc_slow"]), adjust=False).mean()
  90. btc_fast = btc_close.ewm(span=int(p["btc_fast"]), adjust=False).mean()
  91. btc_ret = btc_close / btc_close.shift(int(p["btc_lookback"])) - 1.0
  92. asset_ret = aligned["asset_close"] / aligned["asset_close"].shift(int(p["asset_lookback"])) - 1.0
  93. asset_fast = fast.reindex(aligned.index)
  94. asset_slow = slow.reindex(aligned.index)
  95. entry = (
  96. (btc_close < btc_slow)
  97. & (btc_ret <= -float(p["btc_drop"]))
  98. & (asset_ret <= float(p["asset_max_return"]))
  99. & (aligned["asset_high"] >= asset_fast)
  100. & (aligned["asset_close"] < asset_fast)
  101. & (aligned["asset_close"] < asset_slow)
  102. ).reindex(frame.index, fill_value=False)
  103. exit_ = ((btc_close > btc_fast) | (aligned["asset_close"] > asset_fast)).reindex(frame.index, fill_value=False)
  104. else:
  105. raise ValueError(f"unknown family: {strategy.family}")
  106. return pd.DataFrame({"entry": entry.fillna(False), "exit": exit_.fillna(False), "atr": atr}, index=frame.index)
  107. def close_short(entry_price: float, exit_price: float, equity: float) -> tuple[float, float]:
  108. gross_return = entry_price / exit_price - 1.0
  109. net_return = gross_return - ROUNDTRIP_FEE
  110. return max(0.0, equity * (1.0 + net_return)), net_return
  111. def run_strategy(strategy: Strategy, frame: pd.DataFrame, btc_frame: pd.DataFrame | None) -> dict[str, object]:
  112. signals = signal_frame(strategy, frame, btc_frame)
  113. warmup = max(int(strategy.params.get(key, 0)) for key in ("slow", "break_lookback", "exit_lookback", "atr", "vol_window", "btc_slow")) + 2
  114. equity = INITIAL_EQUITY
  115. position: dict[str, float | int | pd.Timestamp] | None = None
  116. pending_entry = False
  117. pending_exit = False
  118. trades: list[dict[str, object]] = []
  119. equity_curve: list[dict[str, object]] = []
  120. rows = list(frame.itertuples())
  121. for index in range(warmup, len(rows)):
  122. row = rows[index]
  123. ts = frame.index[index]
  124. if pending_exit and position is not None:
  125. equity, net_return = close_short(float(position["entry_price"]), float(row.open), equity)
  126. trades.append(
  127. {
  128. "side": "Short",
  129. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  130. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  131. "entry_price": float(position["entry_price"]),
  132. "exit_price": float(row.open),
  133. "return": net_return,
  134. "hold_bars": index - int(position["entry_index"]),
  135. }
  136. )
  137. position = None
  138. pending_exit = False
  139. if pending_entry and position is None and equity > 0.0:
  140. atr = float(signals["atr"].iloc[index - 1])
  141. position = {
  142. "entry_time": ts,
  143. "entry_index": index,
  144. "entry_price": float(row.open),
  145. "stop_price": float(row.open) + atr * float(strategy.params["stop_atr"]),
  146. "take_price": max(0.01, float(row.open) - atr * float(strategy.params["take_atr"])),
  147. }
  148. pending_entry = False
  149. mark_equity = equity
  150. if position is not None:
  151. stop_hit = float(row.high) >= float(position["stop_price"])
  152. take_hit = float(row.low) <= float(position["take_price"])
  153. if stop_hit or take_hit:
  154. exit_price = float(position["stop_price"] if stop_hit else position["take_price"])
  155. equity, net_return = close_short(float(position["entry_price"]), exit_price, equity)
  156. trades.append(
  157. {
  158. "side": "Short",
  159. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  160. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  161. "entry_price": float(position["entry_price"]),
  162. "exit_price": exit_price,
  163. "return": net_return,
  164. "hold_bars": index - int(position["entry_index"]),
  165. }
  166. )
  167. position = None
  168. mark_equity = equity
  169. if position is not None:
  170. gross_return = float(position["entry_price"]) / float(row.close) - 1.0
  171. mark_equity = max(0.0, equity * (1.0 + gross_return - TAKER_FEE))
  172. equity_curve.append({"ts": ts, "equity": mark_equity})
  173. if index == len(rows) - 1 or equity <= 0.0:
  174. continue
  175. if position is not None:
  176. held = index - int(position["entry_index"])
  177. if bool(signals["exit"].iloc[index]) or held >= int(strategy.params["max_hold"]):
  178. pending_exit = True
  179. elif bool(signals["entry"].iloc[index]):
  180. pending_entry = True
  181. if position is not None:
  182. last = rows[-1]
  183. ts = frame.index[-1]
  184. equity, net_return = close_short(float(position["entry_price"]), float(last.close), equity)
  185. trades.append(
  186. {
  187. "side": "Short",
  188. "entry_time": pd.Timestamp(position["entry_time"]).strftime("%Y-%m-%d %H:%M"),
  189. "exit_time": ts.strftime("%Y-%m-%d %H:%M"),
  190. "entry_price": float(position["entry_price"]),
  191. "exit_price": float(last.close),
  192. "return": net_return,
  193. "hold_bars": len(rows) - 1 - int(position["entry_index"]),
  194. }
  195. )
  196. equity_curve.append({"ts": ts, "equity": equity})
  197. return {"trades": trades, "equity_curve": equity_curve}
  198. def daily_equity(result: dict[str, object]) -> pd.Series:
  199. curve = pd.DataFrame(result["equity_curve"])
  200. curve["ts"] = pd.to_datetime(curve["ts"], utc=True)
  201. series = curve.set_index("ts")["equity"].sort_index()
  202. series = pd.concat([pd.Series([INITIAL_EQUITY], index=[series.index[0].normalize()]), series]).sort_index()
  203. series = series.groupby(level=0).last()
  204. index = pd.date_range(series.index[0].normalize(), series.index[-1].normalize(), freq="1D", tz="UTC")
  205. return series.reindex(index.union(series.index)).sort_index().ffill().reindex(index)
  206. def equity_metrics(series: pd.Series) -> dict[str, float]:
  207. total = float(series.iloc[-1] / series.iloc[0] - 1.0)
  208. years = (series.index[-1] - series.index[0]).total_seconds() / 86_400 / 365
  209. annual = (1.0 + total) ** (1.0 / years) - 1.0 if total > -1.0 and years > 0.0 else 0.0
  210. drawdown = float(((series.cummax() - series) / series.cummax()).max())
  211. return {
  212. "total_return": total,
  213. "annualized_return": annual,
  214. "max_drawdown": drawdown,
  215. "calmar": annual / drawdown if drawdown else 0.0,
  216. }
  217. def trade_metrics(trades: list[dict[str, object]]) -> dict[str, float | int]:
  218. returns = [float(trade["return"]) for trade in trades]
  219. wins = [value for value in returns if value > 0.0]
  220. losses = [value for value in returns if value < 0.0]
  221. gross_profit = sum(wins)
  222. gross_loss = abs(sum(losses))
  223. return {
  224. "trades": len(returns),
  225. "win_rate": len(wins) / len(returns) if returns else 0.0,
  226. "profit_factor": gross_profit / gross_loss if gross_loss else 0.0,
  227. "short_trade_ratio": 1.0 if returns else 0.0,
  228. }
  229. def horizon_returns(series: pd.Series) -> dict[str, float]:
  230. out: dict[str, float] = {}
  231. end = series.index[-1]
  232. for label, offset in HORIZONS:
  233. scoped = series[series.index >= end - offset]
  234. out[f"return_{label}"] = float(scoped.iloc[-1] / scoped.iloc[0] - 1.0) if len(scoped) >= 2 else 0.0
  235. return out
  236. def monthly_rows(strategy: Strategy, series: pd.Series) -> pd.DataFrame:
  237. monthly = series.resample("ME").last()
  238. out = pd.DataFrame(
  239. {
  240. "name": strategy.name,
  241. "symbol": strategy.symbol,
  242. "bar": strategy.bar,
  243. "family": strategy.family,
  244. "month": monthly.index.strftime("%Y-%m"),
  245. "start_equity": monthly.shift(1).fillna(series.iloc[0]).to_numpy(),
  246. "end_equity": monthly.to_numpy(),
  247. }
  248. )
  249. out["return"] = out["end_equity"] / out["start_equity"] - 1.0
  250. return out
  251. def yearly_rows(strategy: Strategy, series: pd.Series) -> pd.DataFrame:
  252. yearly = series.resample("YE").last()
  253. out = pd.DataFrame(
  254. {
  255. "name": strategy.name,
  256. "symbol": strategy.symbol,
  257. "bar": strategy.bar,
  258. "family": strategy.family,
  259. "year": yearly.index.strftime("%Y"),
  260. "start_equity": yearly.shift(1).fillna(series.iloc[0]).to_numpy(),
  261. "end_equity": yearly.to_numpy(),
  262. }
  263. )
  264. out["return"] = out["end_equity"] / out["start_equity"] - 1.0
  265. return out
  266. def build_strategies() -> list[Strategy]:
  267. strategies: list[Strategy] = []
  268. holds = {"4H": 90, "1D": 60}
  269. for symbol, bar, fast, slow, break_lookback, retest_window, exit_lookback, stop_atr, take_atr in product(
  270. SYMBOLS,
  271. BARS,
  272. (20, 30, 50),
  273. (80, 120, 200),
  274. (20, 55),
  275. (3, 6),
  276. (10, 20),
  277. (2.0, 3.0),
  278. (4.0, 6.0),
  279. ):
  280. if fast >= slow:
  281. continue
  282. base = {
  283. "fast": fast,
  284. "slow": slow,
  285. "break_lookback": break_lookback,
  286. "retest_window": retest_window,
  287. "exit_lookback": exit_lookback,
  288. "atr": 14,
  289. "stop_atr": stop_atr,
  290. "take_atr": take_atr,
  291. "max_hold": holds[bar],
  292. }
  293. strategies.append(Strategy("trend_break_retest_short", symbol, bar, base))
  294. for vol_quantile in (0.75, 0.85):
  295. strategies.append(Strategy("vol_expansion_rebound_fail_short", symbol, bar, {**base, "vol_window": 120, "vol_quantile": vol_quantile}))
  296. for symbol in ("ETH-USDT-SWAP",):
  297. for bar, btc_lookback, btc_drop, asset_max_return, stop_atr, take_atr in product(
  298. BARS,
  299. (3, 6, 12),
  300. (0.015, 0.025, 0.04),
  301. (-0.02, 0.0, 0.01),
  302. (2.0, 3.0),
  303. (4.0, 6.0),
  304. ):
  305. strategies.append(
  306. Strategy(
  307. "btc_weakness_transmission_short",
  308. symbol,
  309. bar,
  310. {
  311. "fast": 20,
  312. "slow": 80,
  313. "break_lookback": 20,
  314. "retest_window": 3,
  315. "exit_lookback": 10,
  316. "atr": 14,
  317. "stop_atr": stop_atr,
  318. "take_atr": take_atr,
  319. "max_hold": holds[bar],
  320. "btc_fast": 20,
  321. "btc_slow": 80,
  322. "btc_lookback": btc_lookback,
  323. "asset_lookback": 3,
  324. "btc_drop": btc_drop,
  325. "asset_max_return": asset_max_return,
  326. },
  327. )
  328. )
  329. return strategies
  330. def format_cell(value: object) -> str:
  331. if isinstance(value, float):
  332. return f"{value:.6g}"
  333. return str(value).replace("|", "\\|")
  334. def markdown_table(frame: pd.DataFrame) -> str:
  335. rows = [list(frame.columns), ["---" for _ in frame.columns]]
  336. rows.extend(frame.astype(object).where(pd.notna(frame), "").values.tolist())
  337. return "\n".join("| " + " | ".join(format_cell(value) for value in row) + " |" for row in rows)
  338. def read_only_decision(row: pd.Series) -> str:
  339. if (
  340. row["trades"] >= 30
  341. and row["total_return"] > 0.0
  342. and row["return_3y"] > 0.0
  343. and row["return_1y"] > 0.0
  344. and row["return_6m"] > 0.0
  345. and row["return_3m"] > 0.0
  346. and row["profit_factor"] >= 1.2
  347. and row["calmar"] >= 0.6
  348. ):
  349. return "worth_readonly_observation"
  350. return "do_not_connect"
  351. def markdown_report(command: str, paths: list[Path], totals: pd.DataFrame, monthly: pd.DataFrame, yearly: pd.DataFrame, qualified: pd.DataFrame) -> str:
  352. top = qualified.head(10) if len(qualified) else totals.head(10)
  353. top_names = set(top.head(3)["name"])
  354. conclusion = (
  355. f"{len(qualified)} candidates are worth read-only observation under the explicit filter."
  356. if len(qualified)
  357. else "No candidate is worth connecting to read-only observation under the explicit filter."
  358. )
  359. lines = [
  360. "# Short-Bias Regime Alt Search",
  361. "",
  362. f"Run command: `{command}`",
  363. "",
  364. "Output files:",
  365. *[f"- `{path}`" for path in paths],
  366. "",
  367. "Scope: BTC-USDT-SWAP and ETH-USDT-SWAP perpetuals, resampled from local 15m cache to 4H/1D. SOL was not included because this repository has no local SOL candle cache.",
  368. f"Cost: {TAKER_FEE:.2%} single-side taker fee; closed trades subtract {ROUNDTRIP_FEE:.2%} roundtrip.",
  369. "Candidate families: trend break retest short, BTC weakness transmission to ETH short, and volatility expansion rebound-failure short.",
  370. "",
  371. f"Conclusion: {conclusion}",
  372. "",
  373. "## Top candidates",
  374. "",
  375. markdown_table(
  376. top[
  377. [
  378. "name",
  379. "symbol",
  380. "bar",
  381. "family",
  382. "total_return",
  383. "annualized_return",
  384. "max_drawdown",
  385. "calmar",
  386. "trades",
  387. "win_rate",
  388. "profit_factor",
  389. "short_trade_ratio",
  390. "return_3y",
  391. "return_1y",
  392. "return_6m",
  393. "return_3m",
  394. "readonly_observation",
  395. ]
  396. ]
  397. ),
  398. "",
  399. "## Monthly returns for top 3",
  400. "",
  401. markdown_table(monthly[monthly["name"].isin(top_names)].tail(120)),
  402. "",
  403. "## Year distribution for top 3",
  404. "",
  405. markdown_table(yearly[yearly["name"].isin(top_names)]),
  406. ]
  407. return "\n".join(lines) + "\n"
  408. def main() -> int:
  409. parser = argparse.ArgumentParser()
  410. parser.add_argument("--years", type=float, default=YEARS)
  411. parser.add_argument("--output-dir", type=Path, default=OUTPUT_DIR)
  412. parser.add_argument("--max-candidates", type=int, default=0)
  413. args = parser.parse_args()
  414. raw = {symbol: load_15m_frame(symbol, args.years) for symbol in SYMBOLS}
  415. data = {(symbol, bar): resample_frame(raw[symbol], bar) for symbol in SYMBOLS for bar in BARS}
  416. strategies = build_strategies()
  417. if args.max_candidates:
  418. strategies = strategies[: args.max_candidates]
  419. total_rows: list[dict[str, object]] = []
  420. monthly_frames: list[pd.DataFrame] = []
  421. yearly_frames: list[pd.DataFrame] = []
  422. trade_rows: list[dict[str, object]] = []
  423. for index, strategy in enumerate(strategies, start=1):
  424. btc_frame = data[("BTC-USDT-SWAP", strategy.bar)] if strategy.family == "btc_weakness_transmission_short" else None
  425. result = run_strategy(strategy, data[(strategy.symbol, strategy.bar)], btc_frame)
  426. if not result["equity_curve"]:
  427. continue
  428. series = daily_equity(result)
  429. monthly = monthly_rows(strategy, series)
  430. yearly = yearly_rows(strategy, series)
  431. trades = list(result["trades"])
  432. row = {
  433. "name": strategy.name,
  434. "symbol": strategy.symbol,
  435. "bar": strategy.bar,
  436. "family": strategy.family,
  437. "first_day": series.index[0].strftime("%Y-%m-%d"),
  438. "last_day": series.index[-1].strftime("%Y-%m-%d"),
  439. "fee_single_side": TAKER_FEE,
  440. "roundtrip_fee": ROUNDTRIP_FEE,
  441. "worst_month_return": float(monthly["return"].min()),
  442. **equity_metrics(series),
  443. **trade_metrics(trades),
  444. **horizon_returns(series),
  445. **strategy.params,
  446. }
  447. total_rows.append(row)
  448. monthly_frames.append(monthly)
  449. yearly_frames.append(yearly)
  450. trade_rows.extend({"name": strategy.name, "symbol": strategy.symbol, "bar": strategy.bar, "family": strategy.family, **trade} for trade in trades)
  451. print(f"done {index}/{len(strategies)} {strategy.name}", flush=True)
  452. totals = pd.DataFrame(total_rows).sort_values(["calmar", "annualized_return", "profit_factor"], ascending=[False, False, False])
  453. totals["readonly_observation"] = totals.apply(read_only_decision, axis=1)
  454. monthly = pd.concat(monthly_frames, ignore_index=True)
  455. yearly = pd.concat(yearly_frames, ignore_index=True)
  456. trades = pd.DataFrame(trade_rows)
  457. qualified = totals[totals["readonly_observation"] == "worth_readonly_observation"].copy()
  458. args.output_dir.mkdir(parents=True, exist_ok=True)
  459. totals_path = args.output_dir / f"{PREFIX}-totals.csv"
  460. monthly_path = args.output_dir / f"{PREFIX}-monthly-returns.csv"
  461. yearly_path = args.output_dir / f"{PREFIX}-yearly-distribution.csv"
  462. trades_path = args.output_dir / f"{PREFIX}-trades.csv"
  463. qualified_path = args.output_dir / f"{PREFIX}-qualified.csv"
  464. summary_path = args.output_dir / f"{PREFIX}-summary.json"
  465. report_path = args.output_dir / f"{PREFIX}-report.md"
  466. totals.to_csv(totals_path, index=False)
  467. monthly.to_csv(monthly_path, index=False)
  468. yearly.to_csv(yearly_path, index=False)
  469. trades.to_csv(trades_path, index=False)
  470. qualified.to_csv(qualified_path, index=False)
  471. summary = {
  472. "years_requested": args.years,
  473. "symbols": list(SYMBOLS),
  474. "bars": list(BARS),
  475. "sol_included": False,
  476. "sol_exclusion_reason": "no local SOL candle cache under data/okx-candles",
  477. "strategy_count": len(strategies),
  478. "qualified_count": int(len(qualified)),
  479. "worth_readonly_observation": bool(len(qualified)),
  480. "top_name": str((qualified if len(qualified) else totals).iloc[0]["name"]),
  481. "output_files": [str(path) for path in (totals_path, monthly_path, yearly_path, trades_path, qualified_path, summary_path, report_path)],
  482. }
  483. summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  484. command = f"rtk .venv/bin/python {Path(__file__).as_posix()} --years {args.years}"
  485. report_path.write_text(markdown_report(command, [totals_path, monthly_path, yearly_path, trades_path, qualified_path, summary_path, report_path], totals, monthly, yearly, qualified), encoding="utf-8")
  486. print(totals.head(10).to_string(index=False, formatters={col: format_cell for col in totals.columns}))
  487. print(f"qualified={len(qualified)} wrote={report_path}")
  488. return 0
  489. if __name__ == "__main__":
  490. raise SystemExit(main())