build_calendar_fusion_observation_intent.py 7.6 KB

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