You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

104 lines
5.0 KiB
Python

"""Replay test for the live bar-high breakout helper.
Verifies that ORBTradingEngine._post_orb_bar_high_breakout correctly identifies
spike-and-retrace breakouts using today's actual 2026-05-07 intraday bars
(SHAK, SNDK, ARM). Standalone — does not require the live broker, DB, or
network — bars and breakout levels are pinned in the test data.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from apps.orb_trader.engine import ORBTradingEngine
from libs.intraday.domain import ORBStrategyParams
# Bar-high reference data from /api/v1/alpaca/intraday/today on 2026-05-07.
# Only fields needed by the helper (timestamp, high, low) are kept.
SHAK_BARS = [
{"timestamp": "2026-05-07T13:30:00+00:00", "high": 71.53, "low": 69.075}, # ORB bar
{"timestamp": "2026-05-07T13:35:00+00:00", "high": 70.09, "low": 68.505},
{"timestamp": "2026-05-07T13:40:00+00:00", "high": 70.325, "low": 68.75},
{"timestamp": "2026-05-07T13:45:00+00:00", "high": 70.575, "low": 68.85},
{"timestamp": "2026-05-07T13:50:00+00:00", "high": 69.03, "low": 67.965},
{"timestamp": "2026-05-07T13:55:00+00:00", "high": 68.29, "low": 67.92},
]
ARM_BARS = [
{"timestamp": "2026-05-07T13:30:00+00:00", "high": 231.475, "low": 218.25}, # ORB bar
{"timestamp": "2026-05-07T13:35:00+00:00", "high": 232.19, "low": 223.69}, # crosses inside timeout
{"timestamp": "2026-05-07T13:40:00+00:00", "high": 224.04, "low": 218.25},
{"timestamp": "2026-05-07T13:45:00+00:00", "high": 220.35, "low": 216.95},
{"timestamp": "2026-05-07T13:50:00+00:00", "high": 222.87, "low": 219.21},
{"timestamp": "2026-05-07T13:55:00+00:00", "high": 222.65, "low": 220.29},
]
SNDK_BARS = [
{"timestamp": "2026-05-07T13:30:00+00:00", "high": 1396.475, "low": 1355.3}, # ORB bar
{"timestamp": "2026-05-07T13:35:00+00:00", "high": 1388.94, "low": 1370.69},
{"timestamp": "2026-05-07T13:40:00+00:00", "high": 1373.57, "low": 1342.75},
{"timestamp": "2026-05-07T13:45:00+00:00", "high": 1348.2, "low": 1328.12},
{"timestamp": "2026-05-07T13:50:00+00:00", "high": 1359.66, "low": 1344.165},
{"timestamp": "2026-05-07T13:55:00+00:00", "high": 1367.69, "low": 1352.4},
# Below: post-timeout (after 09:55 ET = 13:55 UTC + 5min boundary).
# SNDK's only crossing of 1396.475 is at 14:20 UTC (10:20 ET), past timeout.
{"timestamp": "2026-05-07T14:00:00+00:00", "high": 1354.98, "low": 1344.62},
{"timestamp": "2026-05-07T14:05:00+00:00", "high": 1366.06, "low": 1345.93},
{"timestamp": "2026-05-07T14:10:00+00:00", "high": 1369.58, "low": 1359.39},
{"timestamp": "2026-05-07T14:15:00+00:00", "high": 1389.71, "low": 1362.02},
{"timestamp": "2026-05-07T14:20:00+00:00", "high": 1399.98, "low": 1377.18}, # crosses, post-timeout
]
def _make_engine(orb_minutes: int = 5, order_timeout_minutes: int = 25) -> ORBTradingEngine:
"""Build an engine instance with only the fields the helper touches."""
params = ORBStrategyParams(
orb_minutes=orb_minutes,
order_timeout_minutes=order_timeout_minutes,
live_breakout_use_bar_high=True,
)
eng = ORBTradingEngine.__new__(ORBTradingEngine)
eng._params = params
eng._session = MagicMock(session_id="test")
return eng
@pytest.mark.parametrize(
"name,bars,direction,breakout_level,expected,reason",
[
("ARM_inside_timeout", ARM_BARS, "bullish", 231.475, True,
"13:35 UTC bar high 232.19 crosses 231.475 inside 25-min timeout"),
("SNDK_post_timeout", SNDK_BARS, "bullish", 1396.475, False,
"Only crossing (14:20 UTC high 1399.98) is past 25-min timeout"),
("SHAK_no_breakout", SHAK_BARS, "bullish", 71.53, False,
"No post-ORB bar high reaches 71.53"),
],
)
def test_bar_high_breakout_replay(name, bars, direction, breakout_level, expected, reason):
eng = _make_engine(orb_minutes=5, order_timeout_minutes=25)
actual = eng._post_orb_bar_high_breakout(
bars, direction, breakout_level, "2026-05-07"
)
assert actual is expected, f"{name}: expected {expected} ({reason}), got {actual}"
def test_bar_high_breakout_skips_orb_bar():
"""The ORB bar's own high must not be allowed to trigger its own breakout
(breakout_level is set FROM that bar, so it always equals it)."""
eng = _make_engine(orb_minutes=5, order_timeout_minutes=25)
# Only the ORB bar — no post-ORB bars at all.
bars = [{"timestamp": "2026-05-07T13:30:00+00:00", "high": 100.0, "low": 90.0}]
assert eng._post_orb_bar_high_breakout(bars, "bullish", 100.0, "2026-05-07") is False
def test_bar_high_breakout_short_direction():
"""Bearish direction triggers when bar low <= breakout_level (the ORB low)."""
eng = _make_engine(orb_minutes=5, order_timeout_minutes=25)
bars = [
{"timestamp": "2026-05-07T13:30:00+00:00", "high": 100.0, "low": 95.0}, # ORB
{"timestamp": "2026-05-07T13:35:00+00:00", "high": 99.0, "low": 94.5}, # crosses
]
assert eng._post_orb_bar_high_breakout(bars, "bearish", 95.0, "2026-05-07") is True