bbsb_report.py 9.9 KB

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