build_calendar_fusion_observation_intent.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. from __future__ import annotations
  2. import json
  3. import sys
  4. from datetime import UTC, datetime
  5. from pathlib import Path
  6. import pandas as pd
  7. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  8. from scripts import search_eth_btc_calendar_carry as carry
  9. REPORT_DIR = Path("reports/long-short-fusion")
  10. TOTAL_PATH = REPORT_DIR / "fusion-calendar-total.csv"
  11. OUTPUT_JSON = REPORT_DIR / "calendar-fusion-observation-intent.json"
  12. OUTPUT_MD = REPORT_DIR / "calendar-fusion-observation-intent.md"
  13. CANDIDATE_SPEC = carry.Spec("ETH-USDT-SWAP", "1h", "long", 14, "weekend", 8, "calm")
  14. BAR_DELTAS = {"1h": pd.Timedelta(hours=1), "4h": pd.Timedelta(hours=4)}
  15. def iso(ts: pd.Timestamp) -> str:
  16. return ts.isoformat().replace("+00:00", "Z")
  17. def calendar_state() -> dict[str, object]:
  18. frame = carry.resample(carry.load_frame(CANDIDATE_SPEC.symbol), CANDIDATE_SPEC.bar)
  19. returns = frame["close"].pct_change()
  20. vol = returns.rolling(96).std(ddof=0)
  21. vol_rank = vol.rolling(720).rank(pct=True)
  22. allowed_weekdays = carry.WEEKDAY_SETS[CANDIDATE_SPEC.weekdays]
  23. signals = (
  24. (frame.index.hour == CANDIDATE_SPEC.hour)
  25. & pd.Series(frame.index.weekday, index=frame.index).isin(allowed_weekdays)
  26. & (vol_rank <= 0.5)
  27. ).fillna(False)
  28. decision_index = len(frame) - 2
  29. decision_time = frame.index[decision_index]
  30. latest_signal_times = list(signals[signals & (signals.index <= decision_time)].index)
  31. latest_signal_time = latest_signal_times[-1] if latest_signal_times else None
  32. active = False
  33. entry_time = None
  34. exit_time = None
  35. if latest_signal_time is not None:
  36. signal_index = frame.index.get_loc(latest_signal_time)
  37. entry_index = signal_index + 1
  38. exit_index = entry_index + CANDIDATE_SPEC.hold
  39. bar_delta = BAR_DELTAS[CANDIDATE_SPEC.bar]
  40. entry_time = frame.index[entry_index] if entry_index < len(frame) else latest_signal_time + bar_delta
  41. exit_time = frame.index[exit_index] if exit_index < len(frame) else entry_time + (bar_delta * CANDIDATE_SPEC.hold)
  42. active = bool(entry_time <= decision_time < exit_time)
  43. return {
  44. "spec": CANDIDATE_SPEC.name,
  45. "decision_candle_time": iso(decision_time),
  46. "latest_local_candle_time": iso(frame.index[-1]),
  47. "entry_signal_on_decision_candle": bool(signals.iloc[decision_index]),
  48. "vol_rank_on_decision_candle": None if pd.isna(vol_rank.iloc[decision_index]) else float(vol_rank.iloc[decision_index]),
  49. "latest_signal_time": iso(latest_signal_time) if latest_signal_time is not None else None,
  50. "theoretical_entry_time": iso(entry_time) if entry_time is not None else None,
  51. "theoretical_exit_time": iso(exit_time) if exit_time is not None else None,
  52. "theoretical_calendar_position_active": active,
  53. "side": CANDIDATE_SPEC.side,
  54. }
  55. def selected_candidates() -> list[dict[str, object]]:
  56. selected = pd.read_csv(TOTAL_PATH)
  57. selected = selected[
  58. (selected["calendar_weight"] <= 0.125)
  59. & (selected["full_improved_vs_no_calendar"])
  60. & (selected["6m_improved_vs_no_calendar"])
  61. & (selected["3m_improved_vs_no_calendar"])
  62. ].sort_values(["score", "calmar", "total_return"], ascending=False).head(3)
  63. if selected.empty:
  64. raise ValueError("no calendar fusion observation candidates")
  65. rows = []
  66. for label, (_, row) in zip(["A", "B", "C"], selected.iterrows(), strict=True):
  67. rows.append(
  68. {
  69. "label": label,
  70. "name": str(row["name"]),
  71. "calendar_weight": float(row["calendar_weight"]),
  72. "gross_exposure": float(row["gross_exposure"]),
  73. "short_exposure": float(row["short_exposure"]),
  74. "component_weights": {
  75. "long_variant": str(row["long_variant"]),
  76. "long_weight": float(row["long_weight"]),
  77. "btc_risk_short_weight": float(row["btc_risk_short_weight"]),
  78. "eth_4h_vol_short_weight": float(row["eth_4h_vol_short_weight"]),
  79. "btc_4h_vol_short_weight": float(row["btc_4h_vol_short_weight"]),
  80. "eth_4h_vol_short_gated_weight": float(row["eth_4h_vol_short_gated_weight"]),
  81. "btc_4h_vol_short_gated_weight": float(row["btc_4h_vol_short_gated_weight"]),
  82. "calendar_weight": float(row["calendar_weight"]),
  83. },
  84. "metrics": {
  85. "full_total_return": float(row["total_return"]),
  86. "full_annualized_return": float(row["annualized_return"]),
  87. "full_max_drawdown": float(row["max_drawdown"]),
  88. "full_calmar": float(row["calmar"]),
  89. "h3y_return": float(row["h3y_return"]),
  90. "h1y_return": float(row["h1y_return"]),
  91. "h6m_return": float(row["h6m_return"]),
  92. "h3m_return": float(row["h3m_return"]),
  93. },
  94. }
  95. )
  96. return rows
  97. def build_payload() -> dict[str, object]:
  98. return {
  99. "mode": "readonly_observation_intent",
  100. "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
  101. "submitted_orders": 0,
  102. "private_key_required": False,
  103. "strategy_family": "calendar_fusion_observation",
  104. "source_report": "reports/long-short-fusion/fusion-calendar-total.csv",
  105. "risk_limits": {
  106. "no_order_submission": True,
  107. "no_cancel_submission": True,
  108. "blocked_for_live_trading": True,
  109. "reason": "candidate requires read-only observation before any live promotion",
  110. },
  111. "calendar_state": calendar_state(),
  112. "candidates": selected_candidates(),
  113. }
  114. def markdown(payload: dict[str, object]) -> str:
  115. state = payload["calendar_state"]
  116. lines = [
  117. "# Calendar Fusion Observation Intent",
  118. "",
  119. "Read-only observation intent. No order or cancel request was submitted.",
  120. "",
  121. "## Calendar Leg",
  122. "",
  123. f"- Spec: `{state['spec']}`",
  124. f"- Decision candle: `{state['decision_candle_time']}`",
  125. f"- Latest local candle: `{state['latest_local_candle_time']}`",
  126. f"- Entry signal on decision candle: `{state['entry_signal_on_decision_candle']}`",
  127. f"- Theoretical active position: `{state['theoretical_calendar_position_active']}`",
  128. f"- Latest signal time: `{state['latest_signal_time']}`",
  129. f"- Theoretical entry: `{state['theoretical_entry_time']}`",
  130. f"- Theoretical exit: `{state['theoretical_exit_time']}`",
  131. "",
  132. "## Candidates",
  133. "",
  134. "| label | calendar_weight | gross_exposure | short_exposure | full_return | max_dd | calmar |",
  135. "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
  136. ]
  137. for row in payload["candidates"]:
  138. metrics = row["metrics"]
  139. lines.append(
  140. f"| {row['label']} | {row['calendar_weight']:.3f} | {row['gross_exposure']:.3f} | {row['short_exposure']:.3f} | "
  141. f"{metrics['full_total_return']:.2%} | {metrics['full_max_drawdown']:.2%} | {metrics['full_calmar']:.2f} |"
  142. )
  143. lines.extend(
  144. [
  145. "",
  146. "## Intent JSON",
  147. "",
  148. "```json",
  149. json.dumps(payload, indent=2, sort_keys=True),
  150. "```",
  151. "",
  152. ]
  153. )
  154. return "\n".join(lines)
  155. def main() -> int:
  156. payload = build_payload()
  157. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  158. OUTPUT_JSON.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  159. OUTPUT_MD.write_text(markdown(payload), encoding="utf-8")
  160. print(json.dumps(payload, indent=2, sort_keys=True))
  161. return 0
  162. if __name__ == "__main__":
  163. raise SystemExit(main())