test_run_bb_squeeze_executor.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. from dataclasses import replace
  2. import pytest
  3. from okx_codex_trader.live_execution import TargetPosition
  4. from okx_codex_trader.models import Candle
  5. from scripts.run_bb_squeeze_executor import EMPTY_STATE, aligned_frame_from_candles, refresh_live_frame, run_once, save_state, signal_from_frame, strategy_name
  6. def candles_with_latest_breakout() -> list[Candle]:
  7. candles = []
  8. price = 100.0
  9. for index in range(1_001):
  10. if index == 1_000:
  11. price = 101.5
  12. candles.append(
  13. Candle(
  14. symbol="ETH-USDT-SWAP",
  15. ts=1_700_000_000_000 + (index * 900_000),
  16. open=price,
  17. high=price,
  18. low=price,
  19. close=price,
  20. volume=1_000.0,
  21. )
  22. )
  23. return candles
  24. def btc_up_candles(timestamps: list[int]) -> list[Candle]:
  25. return [
  26. Candle(
  27. symbol="BTC-USDT-SWAP",
  28. ts=ts,
  29. open=100.0 + index * 0.01,
  30. high=100.0 + index * 0.01,
  31. low=100.0 + index * 0.01,
  32. close=100.0 + index * 0.01,
  33. volume=1_000.0,
  34. )
  35. for index, ts in enumerate(timestamps)
  36. ]
  37. def btc_down_candles(timestamps: list[int]) -> list[Candle]:
  38. return [
  39. Candle(
  40. symbol="BTC-USDT-SWAP",
  41. ts=ts,
  42. open=200.0 - index * 0.01,
  43. high=200.0 - index * 0.01,
  44. low=200.0 - index * 0.01,
  45. close=200.0 - index * 0.01,
  46. volume=1_000.0,
  47. )
  48. for index, ts in enumerate(timestamps)
  49. ]
  50. def live_frame(candles: list[Candle]):
  51. return aligned_frame_from_candles(candles, btc_up_candles([candle.ts for candle in candles]))
  52. def test_strategy_name_matches_live_parameters() -> None:
  53. assert strategy_name() == (
  54. "bb-rr-time-l96-bw960-q0.25-sl0.01-rr3-hybrid_signal_rr-both-btc-up-"
  55. "vc0.006-ddnone-cd24-mxbuf0.001-mxc1-entryweekday-openexitskip-be0.008-0.001"
  56. )
  57. def test_signal_uses_latest_confirmed_candle() -> None:
  58. candles = candles_with_latest_breakout()
  59. frame = live_frame(candles)
  60. next_state, signal = signal_from_frame(frame, EMPTY_STATE)
  61. assert signal["decision_candle_ts"] == candles[-1].ts
  62. assert next_state.last_candle_ts == candles[-1].ts
  63. def test_seen_latest_candle_replays_without_order_signal() -> None:
  64. candles = candles_with_latest_breakout()
  65. frame = live_frame(candles)
  66. state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
  67. _, signal = signal_from_frame(frame, state)
  68. assert signal["decision_candle_ts"] == candles[-1].ts
  69. assert signal["signal"] == "state_replay"
  70. def test_loop_predicate_skips_seen_decision_candle() -> None:
  71. candles = candles_with_latest_breakout()
  72. frame = live_frame(candles)
  73. state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
  74. _, signal = signal_from_frame(frame, state)
  75. assert signal["signal"] == "state_replay"
  76. def test_refresh_live_frame_fetches_recent_candles_after_initial_load() -> None:
  77. class Client:
  78. def __init__(self) -> None:
  79. self.limits = []
  80. def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
  81. self.limits.append(limit)
  82. return candles_with_latest_breakout()
  83. def get_recent_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
  84. self.limits.append(limit)
  85. candles = candles_with_latest_breakout()
  86. return [
  87. *candles[-19:],
  88. Candle(
  89. symbol="ETH-USDT-SWAP",
  90. ts=candles[-1].ts + 900_000,
  91. open=102.0,
  92. high=102.0,
  93. low=102.0,
  94. close=102.0,
  95. volume=1_000.0,
  96. ),
  97. ]
  98. client = Client()
  99. initial = refresh_live_frame(client, None)
  100. refreshed = refresh_live_frame(client, initial)
  101. assert client.limits == [1_200, 1_200, 20, 20]
  102. assert int(refreshed.iloc[-1]["ts"]) == int(initial.iloc[-1]["ts"]) + 900_000
  103. def middle_exit_candles(close_multiplier: float) -> list[Candle]:
  104. candles = []
  105. for index in range(1_001):
  106. close = 100.0
  107. if index == 1_000:
  108. close = 100.0 * close_multiplier
  109. candles.append(
  110. Candle(
  111. symbol="ETH-USDT-SWAP",
  112. ts=1_700_000_000_000 + (index * 900_000),
  113. open=close,
  114. high=close,
  115. low=close,
  116. close=close,
  117. volume=1_000.0,
  118. )
  119. )
  120. return candles
  121. def shift_candles(candles: list[Candle], offset_ms: int) -> list[Candle]:
  122. return [
  123. Candle(candle.symbol, candle.ts + offset_ms, candle.open, candle.high, candle.low, candle.close, candle.volume)
  124. for candle in candles
  125. ]
  126. def test_short_middle_exit_requires_buffer_break() -> None:
  127. candles = middle_exit_candles(1.0004)
  128. frame = live_frame(candles)
  129. state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
  130. _, signal = signal_from_frame(frame, state)
  131. assert signal["signal"] == "hold"
  132. assert signal["target_side"] == "short"
  133. def test_short_middle_exit_triggers_after_buffer_break() -> None:
  134. candles = shift_candles(middle_exit_candles(1.002), 12 * 3_600_000)
  135. frame = live_frame(candles)
  136. state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
  137. _, signal = signal_from_frame(frame, state)
  138. assert signal["signal"] == "exit_middle"
  139. assert signal["target_side"] == "flat"
  140. def test_us_open_middle_exit_is_skipped() -> None:
  141. candles = middle_exit_candles(1.001)
  142. frame = live_frame(candles)
  143. state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
  144. _, signal = signal_from_frame(frame, state)
  145. assert signal["signal"] == "hold"
  146. assert signal["target_side"] == "short"
  147. def test_long_take_profit_triggers_exit() -> None:
  148. candles = middle_exit_candles(1.0)
  149. candles[-1] = Candle(
  150. symbol="ETH-USDT-SWAP",
  151. ts=candles[-1].ts,
  152. open=100.0,
  153. high=103.1,
  154. low=100.0,
  155. close=102.0,
  156. volume=1_000.0,
  157. )
  158. frame = live_frame(candles)
  159. state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
  160. _, signal = signal_from_frame(frame, state)
  161. assert signal["signal"] == "exit_take_profit"
  162. assert signal["target_side"] == "flat"
  163. def test_long_breakeven_protection_updates_after_favorable_move() -> None:
  164. candles = middle_exit_candles(1.0)
  165. candles[-1] = Candle(
  166. symbol="ETH-USDT-SWAP",
  167. ts=candles[-1].ts,
  168. open=100.0,
  169. high=100.9,
  170. low=100.0,
  171. close=100.4,
  172. volume=1_000.0,
  173. )
  174. frame = live_frame(candles)
  175. state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.0)
  176. next_state, signal = signal_from_frame(frame, state)
  177. assert signal["signal"] == "hold"
  178. assert next_state.max_favorable_move_pct == pytest.approx(0.009)
  179. def test_long_breakeven_protection_triggers_exit_after_prior_favorable_move() -> None:
  180. candles = middle_exit_candles(1.0)
  181. candles[-1] = Candle(
  182. symbol="ETH-USDT-SWAP",
  183. ts=candles[-1].ts,
  184. open=100.4,
  185. high=100.5,
  186. low=100.05,
  187. close=100.2,
  188. volume=1_000.0,
  189. )
  190. frame = live_frame(candles)
  191. state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.008)
  192. next_state, signal = signal_from_frame(frame, state)
  193. assert signal["signal"] == "exit_breakeven"
  194. assert signal["target_side"] == "flat"
  195. assert next_state.active_side is None
  196. def test_btc_down_filter_blocks_new_breakout_entry() -> None:
  197. candles = candles_with_latest_breakout()
  198. frame = aligned_frame_from_candles(candles, btc_down_candles([candle.ts for candle in candles]))
  199. _, signal = signal_from_frame(frame, EMPTY_STATE)
  200. assert signal["signal"] == "hold"
  201. assert signal["target_side"] == "flat"
  202. def test_run_once_syncs_external_flat_without_reopening(monkeypatch, tmp_path) -> None:
  203. frame = live_frame(middle_exit_candles(0.999))
  204. state_dir = tmp_path / "state"
  205. previous = replace(
  206. EMPTY_STATE,
  207. active_side="short",
  208. entry_price=101.0,
  209. entry_candle_ts=int(frame.iloc[-2]["ts"]),
  210. last_candle_ts=int(frame.iloc[-2]["ts"]),
  211. middle_exit_streak=0,
  212. )
  213. save_state(state_dir / "runtime-state.json", previous)
  214. monkeypatch.setattr("scripts.run_bb_squeeze_executor.load_config", lambda: object())
  215. monkeypatch.setattr(
  216. "scripts.run_bb_squeeze_executor.account_current_position",
  217. lambda client, margin: (
  218. TargetPosition(side="flat", unit=0.0, known=True, reason="no open OKX position", contracts=0.0),
  219. {"positions": [], "instrument_meta": {"ct_val": 0.1, "lot_sz": 0.01, "min_sz": 0.01}, "mark_price": 100.0},
  220. ),
  221. )
  222. snapshot = run_once(
  223. state_dir=state_dir,
  224. margin_per_unit_usdt=100.0,
  225. max_new_margin_usdt=100.0,
  226. max_total_margin_usdt=200.0,
  227. submit_live=False,
  228. frame=frame,
  229. )
  230. assert snapshot["signal"]["signal"] == "external_flat_sync"
  231. assert snapshot["target_position"]["side"] == "flat"
  232. assert snapshot["rendered_orders"] == []
  233. assert snapshot["next_state"]["active_side"] is None