donchian_report.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. from pathlib import Path
  4. import pandas as pd
  5. from okx_codex_trader.models import Candle
  6. from okx_codex_trader.sampled_report import (
  7. SegmentResult,
  8. generate_sampled_report,
  9. mark_to_market as _mark_to_market,
  10. trade_equity as _trade_equity,
  11. )
  12. DONCHIAN_STRATEGY_DESCRIPTION = (
  13. "Donchian channel breakout, previous-window close breakout entries at next open, "
  14. "opposite-channel exits at next open, intrabar 1.0% stop-loss."
  15. )
  16. @dataclass(frozen=True)
  17. class DonchianConfig:
  18. entry_window: int = 20
  19. exit_window: int = 10
  20. stop_loss_pct: float = 0.01
  21. initial_equity: float = 10_000.0
  22. def _format_ts(ts: int) -> str:
  23. return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
  24. def run_donchian_segment(
  25. *,
  26. candles: list[Candle],
  27. leverage: int,
  28. warmup_bars: int,
  29. config: DonchianConfig = DonchianConfig(),
  30. ) -> SegmentResult:
  31. highs = pd.Series([candle.high for candle in candles], dtype=float)
  32. lows = pd.Series([candle.low for candle in candles], dtype=float)
  33. entry_high = highs.shift(1).rolling(config.entry_window).max().tolist()
  34. entry_low = lows.shift(1).rolling(config.entry_window).min().tolist()
  35. exit_high = highs.shift(1).rolling(config.exit_window).max().tolist()
  36. exit_low = lows.shift(1).rolling(config.exit_window).min().tolist()
  37. equity = config.initial_equity
  38. ending_equity = equity
  39. peak_equity = equity
  40. max_drawdown = 0.0
  41. wins = 0
  42. trades: list[dict[str, object]] = []
  43. entries: list[dict[str, object]] = []
  44. exits: list[dict[str, object]] = []
  45. equity_curve: list[dict[str, float | int]] = []
  46. position: dict[str, object] | None = None
  47. pending_entry_side: str | None = None
  48. pending_exit = False
  49. for index in range(warmup_bars, len(candles)):
  50. candle = candles[index]
  51. if pending_exit and position is not None:
  52. exit_price = candle.open
  53. exit_equity = _trade_equity(
  54. side=str(position["side"]),
  55. margin_used=float(position["margin_used"]),
  56. entry_price=float(position["entry_price"]),
  57. exit_price=exit_price,
  58. leverage=leverage,
  59. )
  60. trades.append(
  61. {
  62. "side": "Long" if position["side"] == "long" else "Short",
  63. "entry_time": _format_ts(int(position["entry_time"])),
  64. "exit_time": _format_ts(candle.ts),
  65. "entry_price": round(float(position["entry_price"]), 4),
  66. "exit_price": round(exit_price, 4),
  67. "pnl": round(exit_equity - float(position["margin_used"]), 4),
  68. "return_pct": round(
  69. (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
  70. 4,
  71. ),
  72. }
  73. )
  74. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  75. if exit_equity > float(position["margin_used"]):
  76. wins += 1
  77. equity = exit_equity
  78. position = None
  79. pending_exit = False
  80. if pending_entry_side is not None and position is None:
  81. entry_price = candle.open
  82. margin_used = equity
  83. stop_multiplier = 1 - config.stop_loss_pct if pending_entry_side == "long" else 1 + config.stop_loss_pct
  84. position = {
  85. "side": pending_entry_side,
  86. "entry_time": candle.ts,
  87. "entry_price": entry_price,
  88. "entry_index": index,
  89. "margin_used": margin_used,
  90. "stop_price": entry_price * stop_multiplier,
  91. }
  92. entries.append({"ts": candle.ts, "price": entry_price, "side": pending_entry_side})
  93. pending_entry_side = None
  94. current_equity = equity
  95. if position is not None:
  96. stop_hit = (
  97. position["side"] == "long" and candle.low <= float(position["stop_price"])
  98. ) or (
  99. position["side"] == "short" and candle.high >= float(position["stop_price"])
  100. )
  101. if stop_hit:
  102. if position["side"] == "long" and candle.open < float(position["stop_price"]):
  103. exit_price = candle.open
  104. elif position["side"] == "short" and candle.open > float(position["stop_price"]):
  105. exit_price = candle.open
  106. else:
  107. exit_price = float(position["stop_price"])
  108. exit_equity = _trade_equity(
  109. side=str(position["side"]),
  110. margin_used=float(position["margin_used"]),
  111. entry_price=float(position["entry_price"]),
  112. exit_price=exit_price,
  113. leverage=leverage,
  114. )
  115. trades.append(
  116. {
  117. "side": "Long" if position["side"] == "long" else "Short",
  118. "entry_time": _format_ts(int(position["entry_time"])),
  119. "exit_time": _format_ts(candle.ts),
  120. "entry_price": round(float(position["entry_price"]), 4),
  121. "exit_price": round(exit_price, 4),
  122. "pnl": round(exit_equity - float(position["margin_used"]), 4),
  123. "return_pct": round(
  124. (exit_equity - float(position["margin_used"])) / float(position["margin_used"]) * 100,
  125. 4,
  126. ),
  127. }
  128. )
  129. exits.append({"ts": candle.ts, "price": exit_price, "side": position["side"]})
  130. if exit_equity > float(position["margin_used"]):
  131. wins += 1
  132. equity = exit_equity
  133. current_equity = exit_equity
  134. position = None
  135. if current_equity > peak_equity:
  136. peak_equity = current_equity
  137. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  138. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  139. ending_equity = current_equity
  140. continue
  141. if position is not None:
  142. current_equity = _mark_to_market(
  143. side=str(position["side"]),
  144. margin_used=float(position["margin_used"]),
  145. entry_price=float(position["entry_price"]),
  146. mark_price=candle.close,
  147. leverage=leverage,
  148. )
  149. if current_equity > peak_equity:
  150. peak_equity = current_equity
  151. max_drawdown = max(max_drawdown, (peak_equity - current_equity) / peak_equity)
  152. equity_curve.append({"ts": candle.ts, "equity": current_equity, "close": candle.close})
  153. ending_equity = current_equity
  154. if index == len(candles) - 1:
  155. continue
  156. if position is not None:
  157. exit_signal = (
  158. position["side"] == "long" and exit_low[index] == exit_low[index] and candle.close < float(exit_low[index])
  159. ) or (
  160. position["side"] == "short" and exit_high[index] == exit_high[index] and candle.close > float(exit_high[index])
  161. )
  162. if exit_signal:
  163. pending_exit = True
  164. continue
  165. if entry_high[index] == entry_high[index] and candle.close > float(entry_high[index]):
  166. pending_entry_side = "long"
  167. elif entry_low[index] == entry_low[index] and candle.close < float(entry_low[index]):
  168. pending_entry_side = "short"
  169. trade_count = len(trades)
  170. return SegmentResult(
  171. trade_count=trade_count,
  172. total_return=(ending_equity - config.initial_equity) / config.initial_equity,
  173. win_rate=(wins / trade_count) if trade_count else 0.0,
  174. max_drawdown=max_drawdown,
  175. trades=trades,
  176. open_position=position,
  177. candles=candles[warmup_bars:],
  178. equity_curve=equity_curve,
  179. entries=entries,
  180. exits=exits,
  181. )
  182. def generate_donchian_sampled_report(
  183. *,
  184. candles: list[Candle],
  185. leverage: int,
  186. output_file: Path,
  187. symbol: str,
  188. bar: str,
  189. segments: int,
  190. window_size: int,
  191. entry_window: int = DonchianConfig.entry_window,
  192. exit_window: int = DonchianConfig.exit_window,
  193. stop_loss_pct: float = DonchianConfig.stop_loss_pct,
  194. ) -> dict[str, object]:
  195. config = DonchianConfig(
  196. entry_window=entry_window,
  197. exit_window=exit_window,
  198. stop_loss_pct=stop_loss_pct,
  199. )
  200. return generate_sampled_report(
  201. candles=candles,
  202. leverage=leverage,
  203. output_file=output_file,
  204. symbol=symbol,
  205. bar=bar,
  206. segments=segments,
  207. window_size=window_size,
  208. report_title="Donchian Sampled Report",
  209. strategy_label="Donchian",
  210. strategy_description=DONCHIAN_STRATEGY_DESCRIPTION,
  211. strategy_params={
  212. "entry_window": config.entry_window,
  213. "exit_window": config.exit_window,
  214. "stop_loss_pct": config.stop_loss_pct,
  215. },
  216. run_segment=lambda *, candles, leverage, warmup_bars: run_donchian_segment(
  217. candles=candles,
  218. leverage=leverage,
  219. warmup_bars=warmup_bars,
  220. config=config,
  221. ),
  222. warmup_bars=max(config.entry_window, config.exit_window),
  223. )