build_eth_conservative_portfolio_report.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import csv
  4. import json
  5. from pathlib import Path
  6. from typing import Any
  7. ROOT = Path(__file__).resolve().parents[1]
  8. REPORT_DIR = ROOT / "reports" / "eth-exploration"
  9. MD_OUT = REPORT_DIR / "eth-conservative-portfolio-final.md"
  10. JSON_OUT = REPORT_DIR / "eth-conservative-portfolio-final.json"
  11. def read_csv(name: str) -> list[dict[str, str]]:
  12. with (REPORT_DIR / name).open(newline="", encoding="utf-8") as handle:
  13. return list(csv.DictReader(handle))
  14. def f(row: dict[str, str], key: str) -> float:
  15. value = row.get(key, "")
  16. return float(value) if value not in ("", None) else 0.0
  17. def pct(value: float) -> str:
  18. return f"{value * 100:.2f}%"
  19. def num(value: float) -> str:
  20. return f"{value:.3f}"
  21. def pick(rows: list[dict[str, str]], **where: str) -> list[dict[str, str]]:
  22. return [row for row in rows if all(row.get(key) == value for key, value in where.items())]
  23. def public_metrics(row: dict[str, str]) -> dict[str, Any]:
  24. keys = [
  25. "cost_model",
  26. "scope",
  27. "trades",
  28. "net_total_return",
  29. "net_annualized_return",
  30. "net_max_drawdown",
  31. "net_calmar",
  32. "worst_month",
  33. "worst_month_return",
  34. "min_horizon_total_return",
  35. "max_horizon_drawdown",
  36. ]
  37. out: dict[str, Any] = {}
  38. for key in keys:
  39. if key not in row:
  40. continue
  41. value = row[key]
  42. if key in {
  43. "trades",
  44. "net_total_return",
  45. "net_annualized_return",
  46. "net_max_drawdown",
  47. "net_calmar",
  48. "worst_month_return",
  49. "min_horizon_total_return",
  50. "max_horizon_drawdown",
  51. }:
  52. out[key] = f(row, key)
  53. else:
  54. out[key] = value
  55. return out
  56. def candidate_from_portfolio(priority: int, title: str, row: dict[str, str], decision: str, next_step: str, real_live_now: bool) -> dict[str, Any]:
  57. return {
  58. "priority": priority,
  59. "title": title,
  60. "name": row["portfolio"],
  61. "status": "candidate",
  62. "real_live_now": real_live_now,
  63. "needs_forward_or_demo_live": True,
  64. "minimum_next_step": next_step,
  65. "decision": decision,
  66. "metrics": public_metrics(row),
  67. "legs": row["legs"].split(";"),
  68. "weights": row["weights"].split(";"),
  69. }
  70. def candidate_from_bb(priority: int, row: dict[str, str]) -> dict[str, Any]:
  71. return {
  72. "priority": priority,
  73. "title": "BB squeeze risk candidate",
  74. "name": row["name"],
  75. "status": "watchlist",
  76. "real_live_now": False,
  77. "needs_forward_or_demo_live": True,
  78. "minimum_next_step": "Run shadow/demo signal intent logging only; require fresh forward trades before any capital allocation because the acceptable-risk versions have few trades.",
  79. "decision": "Keep as secondary watchlist, not primary allocation.",
  80. "metrics": public_metrics(row),
  81. }
  82. def build_payload() -> dict[str, Any]:
  83. portfolios = read_csv("eth-focused-portfolio-conservative-qualified.csv")
  84. bb = read_csv("eth-bb-squeeze-risk-10y-summary.csv")
  85. twap_cons = read_csv("eth-twap-conservative-ranked.csv")
  86. taker_top = read_csv("eth-twap-taker-entry-top15.csv")
  87. maker_qualified = pick(portfolios, cost_model="maker_taker", scope="all_legs")
  88. no_maker = pick(portfolios, cost_model="maker_taker", scope="no_maker_dependent")
  89. all_legs_low_dd = maker_qualified[0]
  90. all_legs_high_return = sorted(maker_qualified, key=lambda row: f(row, "net_annualized_return"), reverse=True)[0]
  91. no_maker_low_dd = no_maker[0]
  92. bb_primary = [
  93. row
  94. for row in bb
  95. if row.get("cost") == "maker_taker"
  96. and f(row, "net_max_drawdown") <= 0.45
  97. and f(row, "worst_month_return") >= -0.25
  98. and f(row, "net_calmar") > 1.0
  99. ]
  100. bb_primary.sort(key=lambda row: (f(row, "net_calmar"), f(row, "net_annualized_return")), reverse=True)
  101. twap_nearest = twap_cons[0]
  102. candidates = [
  103. candidate_from_portfolio(
  104. 1,
  105. "Lowest-drawdown ETH-focused conservative portfolio",
  106. all_legs_low_dd,
  107. "Primary next item to watch in paper/demo. It is the cleanest qualified portfolio by conservative sort, but it contains a maker-dependent TWAP leg, so real funds should wait for live fill evidence.",
  108. "Run quasi-live read-only/order-intent tracking for all legs and record per-leg signal, fill/miss, slippage, and portfolio equity for at least the next signal cycle set.",
  109. False,
  110. ),
  111. candidate_from_portfolio(
  112. 2,
  113. "Simpler no-maker-dependent conservative portfolio",
  114. no_maker_low_dd,
  115. "Best fallback if maker-fill uncertainty is treated as disqualifying. It keeps qualified portfolio behavior without the robust TWAP maker-dependent leg.",
  116. "Shadow the two legs side by side with the primary portfolio and compare realized signal overlap and drawdown path.",
  117. False,
  118. ),
  119. candidate_from_portfolio(
  120. 3,
  121. "Highest-return qualified conservative portfolio",
  122. all_legs_high_return,
  123. "Return-oriented qualified variant. It is less conservative than priority 1 because drawdown is materially higher.",
  124. "Keep as a benchmark portfolio in the same quasi-live tracker; do not allocate before it beats priority 1 on realized drawdown-adjusted behavior.",
  125. False,
  126. ),
  127. ]
  128. if bb_primary:
  129. candidates.append(candidate_from_bb(4, bb_primary[0]))
  130. rejected = [
  131. {
  132. "name": "ETH robust TWAP standalone under conservative maker assumptions",
  133. "status": "do_not_trade_standalone",
  134. "reason": "Independent validation matched the closed-trade report, but conservative maker fill/slippage assumptions had no qualified candidate with all 3y/1y/6m/3m horizons positive.",
  135. "nearest_miss": {
  136. "name": twap_nearest["name"],
  137. "trades": f(twap_nearest, "trades"),
  138. "net_total_return": f(twap_nearest, "net_total_return"),
  139. "net_annualized_return": f(twap_nearest, "net_annualized_return"),
  140. "net_max_drawdown": f(twap_nearest, "net_max_drawdown"),
  141. "min_horizon_total_return": f(twap_nearest, "min_horizon_total_return"),
  142. "worst_365_total_return": f(twap_nearest, "worst_365_total_return"),
  143. },
  144. "minimum_next_step": "Use only as a portfolio leg in shadow/demo tracking until actual maker fill and slippage data contradicts the conservative stress result.",
  145. },
  146. {
  147. "name": "ETH taker-entry TWAP",
  148. "status": "rejected",
  149. "reason": "The taker-entry search produced no eligible taker_taker candidate with positive Calmar across 3y/1y/6m/3m.",
  150. "eligible_rows": len(taker_top),
  151. "minimum_next_step": "No live work. Drop from the next real/paper-live shortlist.",
  152. },
  153. ]
  154. return {
  155. "report": "eth-conservative-portfolio-final",
  156. "generated_from_existing_outputs_only": True,
  157. "source_files": [
  158. "eth-focused-portfolio-conservative-qualified.csv",
  159. "eth-focused-portfolio-conservative-report.md",
  160. "eth-twap-conservative-summary.md",
  161. "eth-robust-twap-validation-summary.md",
  162. "eth-robust-twap-fill-slippage-summary.md",
  163. "eth-twap-taker-entry-summary.md",
  164. "eth-bb-squeeze-risk-10y-report.md",
  165. "eth-signal-intent-readonly.md",
  166. "eth-signal-intent-readonly.json",
  167. ],
  168. "topline_decision": "Watch the conservative portfolio layer next, not the standalone ETH TWAP. Use quasi-live/demo read-only intent first; real funds are not the minimum next step.",
  169. "candidates": candidates,
  170. "rejected_or_deprioritized": rejected,
  171. "current_signal_intent": {
  172. "completed": True,
  173. "latest_confirmed_candle_utc": "2026-04-29 17:00:00",
  174. "signal": False,
  175. "orders_produced": 0,
  176. },
  177. }
  178. def md_table(rows: list[list[str]]) -> str:
  179. header = rows[0]
  180. body = rows[1:]
  181. lines = [
  182. "| " + " | ".join(header) + " |",
  183. "| " + " | ".join(["---"] * len(header)) + " |",
  184. ]
  185. lines.extend("| " + " | ".join(row) + " |" for row in body)
  186. return "\n".join(lines)
  187. def render_markdown(payload: dict[str, Any]) -> str:
  188. rows = [["Priority", "Candidate", "Return", "Ann.", "MDD", "Worst month", "Risk", "Real live now", "Minimum next step"]]
  189. for item in payload["candidates"]:
  190. m = item["metrics"]
  191. rows.append(
  192. [
  193. str(item["priority"]),
  194. item["title"],
  195. pct(float(m.get("net_total_return", 0.0))),
  196. pct(float(m.get("net_annualized_return", 0.0))),
  197. pct(float(m.get("net_max_drawdown", 0.0))),
  198. pct(float(m.get("worst_month_return", 0.0))) if "worst_month_return" in m else "",
  199. num(float(m.get("net_calmar", 0.0))),
  200. "No" if not item["real_live_now"] else "Yes",
  201. item["minimum_next_step"],
  202. ]
  203. )
  204. rejected_rows = [["Item", "Status", "Key reason", "Minimum next step"]]
  205. for item in payload["rejected_or_deprioritized"]:
  206. rejected_rows.append([item["name"], item["status"], item["reason"], item["minimum_next_step"]])
  207. lines = [
  208. "# ETH conservative portfolio final decision report",
  209. "",
  210. "This report only consolidates existing ETH exploration outputs. It does not run a new search.",
  211. "",
  212. "## Topline",
  213. "",
  214. payload["topline_decision"],
  215. "",
  216. "The next thing to watch is the portfolio layer: qualified ETH-focused conservative portfolios exist, while standalone ETH TWAP is not stable enough under conservative maker-fill assumptions and taker-entry TWAP is rejected.",
  217. "",
  218. "## Recommended priority",
  219. "",
  220. md_table(rows),
  221. "",
  222. "## Candidate notes",
  223. "",
  224. ]
  225. for item in payload["candidates"]:
  226. lines.extend(
  227. [
  228. f"### P{item['priority']} {item['title']}",
  229. "",
  230. f"- Name: `{item['name']}`",
  231. f"- Decision: {item['decision']}",
  232. f"- Needs live/quasi-live evidence: {'Yes' if item['needs_forward_or_demo_live'] else 'No'}",
  233. f"- Real funds now: {'Yes' if item['real_live_now'] else 'No'}",
  234. "",
  235. ]
  236. )
  237. if "legs" in item:
  238. lines.append("Legs and weights:")
  239. for leg, weight in zip(item["legs"], item["weights"]):
  240. lines.append(f"- `{leg}` at `{weight.split('=')[-1]}`")
  241. lines.append("")
  242. lines.extend(
  243. [
  244. "## Deprioritized or rejected",
  245. "",
  246. md_table(rejected_rows),
  247. "",
  248. "## Signal intent status",
  249. "",
  250. "Readonly signal intent was completed. Latest confirmed candle was `2026-04-29 17:00:00 UTC`; signal was false and no order intent was produced.",
  251. "",
  252. "## Source outputs",
  253. "",
  254. ]
  255. )
  256. lines.extend(f"- `{name}`" for name in payload["source_files"])
  257. lines.append("")
  258. return "\n".join(lines)
  259. def main() -> int:
  260. payload = build_payload()
  261. JSON_OUT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  262. MD_OUT.write_text(render_markdown(payload), encoding="utf-8")
  263. print(MD_OUT)
  264. print(JSON_OUT)
  265. return 0
  266. if __name__ == "__main__":
  267. raise SystemExit(main())