test_bbsb_report.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import pytest
  2. from okx_codex_trader import bbsb_report
  3. from okx_codex_trader.bbsb_report import generate_bbsb_sampled_report, run_bbsb_segment
  4. from okx_codex_trader.models import Candle
  5. from okx_codex_trader.sampled_report import SegmentResult
  6. def build_candles_from_closes(closes: list[float]) -> list[Candle]:
  7. candles: list[Candle] = []
  8. for index, close in enumerate(closes):
  9. candles.append(
  10. Candle(
  11. symbol="BTC-USDT-SWAP",
  12. ts=index * 60_000,
  13. open=close,
  14. high=close + 1.0,
  15. low=close - 1.0,
  16. close=close,
  17. volume=1_000.0 + index,
  18. )
  19. )
  20. return candles
  21. def make_candle(index: int, open_price: float, high: float, low: float, close: float) -> Candle:
  22. return Candle(
  23. symbol="BTC-USDT-SWAP",
  24. ts=index * 60_000,
  25. open=open_price,
  26. high=high,
  27. low=low,
  28. close=close,
  29. volume=1_000.0 + index,
  30. )
  31. def build_warmup() -> list[Candle]:
  32. candles: list[Candle] = []
  33. for index in range(bbsb_report.WARMUP_BARS):
  34. close = 100.5 if index % 2 else 99.5
  35. candles.append(make_candle(index, close, close + 0.2, close - 0.2, close))
  36. return candles
  37. def build_long_breakout_fixture() -> list[Candle]:
  38. candles = build_warmup()
  39. base = len(candles)
  40. for offset in range(19):
  41. candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
  42. candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
  43. candles.append(make_candle(base + 20, 100.2, 100.3, 100.15, 100.25))
  44. candles.append(make_candle(base + 21, 100.25, 101.3, 100.2, 101.0))
  45. return candles
  46. def build_short_breakout_fixture() -> list[Candle]:
  47. candles = build_warmup()
  48. base = len(candles)
  49. for offset in range(19):
  50. candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
  51. candles.append(make_candle(base + 19, 100.0, 100.02, 99.75, 99.8))
  52. candles.append(make_candle(base + 20, 99.8, 99.85, 99.7, 99.75))
  53. candles.append(make_candle(base + 21, 99.75, 99.8, 98.7, 98.9))
  54. return candles
  55. def build_ambiguous_exit_fixture() -> list[Candle]:
  56. candles = build_long_breakout_fixture()[:-1]
  57. base = len(candles)
  58. candles.append(make_candle(base, 100.25, 101.4, 99.6, 100.8))
  59. return candles
  60. def build_final_bar_breakout_fixture() -> list[Candle]:
  61. candles = build_warmup()
  62. base = len(candles)
  63. candles.append(make_candle(base, 100.0, 100.02, 99.98, 100.0))
  64. candles.append(make_candle(base + 1, 100.0, 100.25, 99.98, 100.2))
  65. return candles
  66. def build_open_tail_fixture() -> list[Candle]:
  67. candles = build_warmup()
  68. base = len(candles)
  69. for offset in range(19):
  70. candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
  71. candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
  72. candles.append(make_candle(base + 20, 100.2, 100.3, 100.15, 100.25))
  73. candles.append(make_candle(base + 21, 100.25, 100.9, 100.2, 100.6))
  74. return candles
  75. def build_entry_bar_tp_sl_fixture() -> list[Candle]:
  76. candles = build_warmup()
  77. base = len(candles)
  78. for offset in range(19):
  79. candles.append(make_candle(base + offset, 100.0, 100.02, 99.98, 100.0))
  80. candles.append(make_candle(base + 19, 100.0, 100.25, 99.98, 100.2))
  81. candles.append(make_candle(base + 20, 100.2, 101.4, 99.6, 100.5))
  82. return candles
  83. def build_same_bar_reentry_fixture() -> list[Candle]:
  84. candles = build_long_breakout_fixture()[:-1]
  85. base = len(candles)
  86. candles.append(make_candle(base, 100.25, 101.3, 100.2, 100.8))
  87. candles.append(make_candle(base + 1, 100.8, 101.0, 99.7, 99.8))
  88. return candles
  89. def test_run_bbsb_segment_produces_long_breakout_trade():
  90. result = run_bbsb_segment(candles=build_long_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  91. assert isinstance(result, SegmentResult)
  92. assert result.trade_count == 1
  93. assert result.trades[0]["side"] == "Long"
  94. def test_run_bbsb_segment_produces_short_breakout_trade():
  95. result = run_bbsb_segment(candles=build_short_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  96. assert isinstance(result, SegmentResult)
  97. assert result.trade_count == 1
  98. assert result.trades[0]["side"] == "Short"
  99. def test_run_bbsb_segment_stop_loss_takes_precedence_over_take_profit():
  100. result = run_bbsb_segment(candles=build_ambiguous_exit_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  101. assert result.trade_count == 1
  102. assert result.trades[0]["exit_price"] == pytest.approx(99.699)
  103. def test_run_bbsb_segment_does_not_generate_entry_from_final_reported_candle():
  104. result = run_bbsb_segment(candles=build_final_bar_breakout_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  105. assert result.trade_count == 0
  106. assert result.trades == []
  107. def test_run_bbsb_segment_marks_open_position_to_market_but_keeps_journal_realized_only():
  108. result = run_bbsb_segment(candles=build_open_tail_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  109. assert result.trade_count == 0
  110. assert result.trades == []
  111. assert result.total_return != 0
  112. assert result.open_position is not None
  113. def test_run_bbsb_segment_allows_tp_or_sl_on_entry_candle():
  114. result = run_bbsb_segment(candles=build_entry_bar_tp_sl_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  115. assert result.trade_count == 1
  116. assert result.open_position is None
  117. def test_run_bbsb_segment_exit_exhausts_bar_without_same_bar_reentry():
  118. result = run_bbsb_segment(candles=build_same_bar_reentry_fixture(), leverage=2, warmup_bars=bbsb_report.WARMUP_BARS)
  119. assert result.trade_count == 1
  120. assert len(result.entries) == 1
  121. def test_generate_bbsb_sampled_report_forwards_shared_report_arguments(monkeypatch, tmp_path):
  122. expected = {"report_file": str(tmp_path / "bbsb.html"), "segment_count": 2}
  123. captured: dict[str, object] = {}
  124. def fake_generate_sampled_report(**kwargs):
  125. captured.update(kwargs)
  126. return expected
  127. monkeypatch.setattr(bbsb_report, "generate_sampled_report", fake_generate_sampled_report)
  128. result = generate_bbsb_sampled_report(
  129. candles=build_candles_from_closes([100.0 + index for index in range(5_000)]),
  130. leverage=2,
  131. output_file=tmp_path / "bbsb.html",
  132. symbol="BTC-USDT-SWAP",
  133. bar="3m",
  134. segments=2,
  135. window_size=300,
  136. )
  137. assert result == expected
  138. assert captured["report_title"] == "BBSB Sampled Report"
  139. assert captured["strategy_label"] == "BBSB"
  140. assert captured["run_segment"] is run_bbsb_segment
  141. assert captured["strategy_params"] == {
  142. "band_length": 20,
  143. "std_multiplier": 2.0,
  144. "bandwidth_lookback": 50,
  145. "take_profit_pct": 0.01,
  146. "stop_loss_pct": 0.005,
  147. }