test_donchian_report.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import pytest
  2. from okx_codex_trader import donchian_report
  3. from okx_codex_trader.donchian_report import (
  4. DonchianConfig,
  5. generate_donchian_sampled_report,
  6. run_donchian_segment,
  7. )
  8. from okx_codex_trader.models import Candle
  9. from okx_codex_trader.sampled_report import SegmentResult
  10. def make_candle(index: int, open_price: float, high: float, low: float, close: float) -> Candle:
  11. return Candle(
  12. symbol="BTC-USDT-SWAP",
  13. ts=index * 60_000,
  14. open=open_price,
  15. high=high,
  16. low=low,
  17. close=close,
  18. volume=1_000.0 + index,
  19. )
  20. def build_linear_candles(count: int) -> list[Candle]:
  21. candles: list[Candle] = []
  22. for index in range(count):
  23. price = 100.0 + index
  24. candles.append(make_candle(index, price, price + 1.0, price - 1.0, price))
  25. return candles
  26. def build_long_trade_fixture() -> list[Candle]:
  27. return [
  28. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  29. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  30. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  31. make_candle(3, 101.0, 102.5, 100.5, 102.0),
  32. make_candle(4, 103.0, 103.5, 102.0, 103.0),
  33. make_candle(5, 102.5, 102.8, 99.8, 100.0),
  34. make_candle(6, 99.9, 100.2, 99.2, 99.7),
  35. ]
  36. def build_short_trade_fixture() -> list[Candle]:
  37. return [
  38. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  39. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  40. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  41. make_candle(3, 99.0, 99.5, 97.5, 98.0),
  42. make_candle(4, 97.0, 97.5, 96.5, 97.0),
  43. make_candle(5, 97.2, 100.5, 96.8, 100.0),
  44. make_candle(6, 100.1, 100.6, 99.5, 100.3),
  45. ]
  46. def build_stop_loss_fixture() -> list[Candle]:
  47. return [
  48. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  49. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  50. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  51. make_candle(3, 101.0, 102.5, 100.5, 102.0),
  52. make_candle(4, 103.0, 103.5, 102.5, 103.0),
  53. make_candle(5, 103.0, 103.2, 99.8, 100.0),
  54. ]
  55. def build_entry_bar_stop_loss_fixture() -> list[Candle]:
  56. return [
  57. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  58. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  59. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  60. make_candle(3, 101.0, 102.5, 100.5, 102.0),
  61. make_candle(4, 103.0, 103.5, 101.0, 102.5),
  62. ]
  63. def build_gap_through_stop_fixture() -> list[Candle]:
  64. return [
  65. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  66. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  67. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  68. make_candle(3, 101.0, 102.5, 100.5, 102.0),
  69. make_candle(4, 103.0, 103.5, 102.5, 103.0),
  70. make_candle(5, 101.0, 101.2, 99.8, 100.0),
  71. ]
  72. def build_final_bar_breakout_fixture() -> list[Candle]:
  73. return [
  74. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  75. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  76. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  77. make_candle(3, 100.0, 101.0, 99.0, 100.0),
  78. make_candle(4, 101.0, 102.5, 100.5, 102.0),
  79. ]
  80. def build_open_tail_fixture() -> list[Candle]:
  81. return [
  82. make_candle(0, 100.0, 101.0, 99.0, 100.0),
  83. make_candle(1, 100.0, 101.0, 99.0, 100.0),
  84. make_candle(2, 100.0, 101.0, 99.0, 100.0),
  85. make_candle(3, 101.0, 102.5, 100.5, 102.0),
  86. make_candle(4, 103.0, 104.0, 102.5, 104.0),
  87. make_candle(5, 104.0, 105.5, 103.5, 105.0),
  88. ]
  89. def test_run_donchian_segment_produces_long_trade():
  90. result = run_donchian_segment(
  91. candles=build_long_trade_fixture(),
  92. leverage=2,
  93. warmup_bars=3,
  94. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
  95. )
  96. assert isinstance(result, SegmentResult)
  97. assert result.trade_count == 1
  98. assert result.trades[0]["side"] == "Long"
  99. assert result.open_position is None
  100. def test_run_donchian_segment_produces_short_trade():
  101. result = run_donchian_segment(
  102. candles=build_short_trade_fixture(),
  103. leverage=2,
  104. warmup_bars=3,
  105. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
  106. )
  107. assert isinstance(result, SegmentResult)
  108. assert result.trade_count == 1
  109. assert result.trades[0]["side"] == "Short"
  110. assert result.open_position is None
  111. def test_run_donchian_segment_stop_loss_takes_precedence():
  112. result = run_donchian_segment(
  113. candles=build_stop_loss_fixture(),
  114. leverage=2,
  115. warmup_bars=3,
  116. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
  117. )
  118. assert result.trade_count == 1
  119. assert result.trades[0]["side"] == "Long"
  120. assert result.trades[0]["exit_price"] == pytest.approx(101.97)
  121. def test_run_donchian_segment_allows_entry_bar_stop_loss():
  122. result = run_donchian_segment(
  123. candles=build_entry_bar_stop_loss_fixture(),
  124. leverage=2,
  125. warmup_bars=3,
  126. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
  127. )
  128. assert result.trade_count == 1
  129. assert result.open_position is None
  130. assert result.trades[0]["exit_price"] == pytest.approx(101.97)
  131. def test_run_donchian_segment_exits_gap_through_stop_at_open():
  132. result = run_donchian_segment(
  133. candles=build_gap_through_stop_fixture(),
  134. leverage=2,
  135. warmup_bars=3,
  136. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
  137. )
  138. assert result.trade_count == 1
  139. assert result.trades[0]["exit_price"] == pytest.approx(101.0)
  140. def test_run_donchian_segment_does_not_generate_entry_from_final_bar():
  141. result = run_donchian_segment(
  142. candles=build_final_bar_breakout_fixture(),
  143. leverage=2,
  144. warmup_bars=3,
  145. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.01),
  146. )
  147. assert result.trade_count == 0
  148. assert result.entries == []
  149. assert result.open_position is None
  150. def test_run_donchian_segment_marks_open_position_to_market():
  151. result = run_donchian_segment(
  152. candles=build_open_tail_fixture(),
  153. leverage=2,
  154. warmup_bars=3,
  155. config=DonchianConfig(entry_window=3, exit_window=2, stop_loss_pct=0.2),
  156. )
  157. assert result.trade_count == 0
  158. assert result.trades == []
  159. assert result.total_return == pytest.approx((10_388.349514563106 - 10_000.0) / 10_000.0)
  160. assert result.open_position is not None
  161. def test_generate_donchian_sampled_report_uses_shared_shell_defaults(monkeypatch, tmp_path):
  162. candles = build_linear_candles(5_000)
  163. output_file = tmp_path / "donchian.html"
  164. recorded: dict[str, object] = {}
  165. sentinel = {
  166. "report_file": str(output_file),
  167. "segment_count": 2,
  168. "window_size": 300,
  169. "aggregate_trade_count": 4,
  170. "average_return": 0.12,
  171. }
  172. def fake_generate_sampled_report(**kwargs):
  173. recorded.update(kwargs)
  174. return sentinel
  175. monkeypatch.setattr(donchian_report, "generate_sampled_report", fake_generate_sampled_report)
  176. result = generate_donchian_sampled_report(
  177. candles=candles,
  178. leverage=2,
  179. output_file=output_file,
  180. symbol="BTC-USDT-SWAP",
  181. bar="3m",
  182. segments=2,
  183. window_size=300,
  184. )
  185. assert result == sentinel
  186. assert recorded["report_title"] == "Donchian Sampled Report"
  187. assert recorded["strategy_label"] == "Donchian"
  188. assert recorded["strategy_params"] == {
  189. "entry_window": 20,
  190. "exit_window": 10,
  191. "stop_loss_pct": 0.01,
  192. }
  193. assert recorded["warmup_bars"] == 20
  194. assert callable(recorded["run_segment"])