search_short_bias_swing.py 22 KB

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