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.
263 lines
9.9 KiB
Python
263 lines
9.9 KiB
Python
"""Unit tests for live ORB VWAP-reclaim polling."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from apps.orb_trader.engine import ORBTradingEngine
|
|
|
|
_DATE = "2026-01-05"
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
|
|
|
def _bar(time_str: str, *, open_: float, high: float, low: float, close: float, volume: int = 1_000) -> dict:
|
|
return {
|
|
"timestamp": f"{_DATE}T{time_str}-05:00",
|
|
"open": open_,
|
|
"high": high,
|
|
"low": low,
|
|
"close": close,
|
|
"volume": volume,
|
|
}
|
|
|
|
|
|
def _make_engine() -> ORBTradingEngine:
|
|
params = SimpleNamespace(
|
|
sim_bar_minutes=5,
|
|
orb_minutes=5,
|
|
order_timeout_minutes=25,
|
|
nofill_vwap_reclaim_enabled=True,
|
|
nofill_vwap_reclaim_min_score_pct=0.8,
|
|
nofill_vwap_reclaim_max_trades=1,
|
|
nofill_vwap_reclaim_size_scale=0.04,
|
|
soft_day_vwap_reclaim_enabled=False,
|
|
vwap_reclaim_window_start_min=30,
|
|
vwap_reclaim_window_end_min=150,
|
|
vwap_reclaim_require_prior_dip=True,
|
|
vwap_reclaim_min_clearance_pct=0.0,
|
|
vwap_reclaim_require_orb_open_retake=False,
|
|
vwap_reclaim_confirm_rel_vol=None,
|
|
daily_budget_reset=True,
|
|
drawdown_governor_threshold=None,
|
|
drawdown_governor_min_scale=0.30,
|
|
streak_sizing_win_bonus=None,
|
|
streak_sizing_loss_penalty=None,
|
|
streak_sizing_max=2.5,
|
|
streak_sizing_min=0.5,
|
|
max_trades_per_day=3,
|
|
max_simultaneous_entries=3,
|
|
atr_stop_multiplier=0.75,
|
|
risk_per_trade_pct=0.05,
|
|
max_position_pct=0.70,
|
|
broad_gapup_continuation_enabled=False,
|
|
)
|
|
session = SimpleNamespace(
|
|
session_id="test-session",
|
|
session_name="test",
|
|
initial_equity=10_000.0,
|
|
)
|
|
state = MagicMock()
|
|
state.get_open_positions.return_value = []
|
|
state.list_trades.return_value = []
|
|
state.get_equity.return_value = 10_000.0
|
|
state.get_peak_equity.return_value = 10_000.0
|
|
state.get_daily_state.return_value = SimpleNamespace(kill_switch=False)
|
|
|
|
broker = MagicMock()
|
|
broker.get_intraday_bars.return_value = {
|
|
"RECL": [
|
|
_bar("09:30:00", open_=100.0, high=101.0, low=99.8, close=100.5),
|
|
_bar("09:35:00", open_=100.5, high=100.6, low=98.8, close=99.0),
|
|
_bar("09:40:00", open_=99.0, high=99.8, low=98.7, close=99.5),
|
|
_bar("10:00:00", open_=99.8, high=102.0, low=99.7, close=101.5),
|
|
]
|
|
}
|
|
broker.get_account.return_value = SimpleNamespace(buying_power=100_000.0)
|
|
order = SimpleNamespace(id="buy-1")
|
|
broker.submit_market_buy.return_value = order
|
|
broker.get_order.return_value = SimpleNamespace(status="filled", filled_avg_price=103.0)
|
|
|
|
eng = object.__new__(ORBTradingEngine)
|
|
eng._session = session
|
|
eng._params = params
|
|
eng._state = state
|
|
eng._broker = broker
|
|
eng._log_callback = None
|
|
eng._date_str = _DATE
|
|
eng._day_size_scale = 1.0
|
|
eng._soft_day_reason = None
|
|
eng._market_orb_quality_max_trades = None
|
|
eng._market_orb_quality_reason = None
|
|
eng._market_thrust_breadth_override_active = False
|
|
eng._market_thrust_index_breadth_override_active = False
|
|
eng._market_thrust_opening_breadth_override_active = False
|
|
eng._candidates = [
|
|
{
|
|
"ticker": "RECL",
|
|
"direction": "bullish",
|
|
"orb_bar": broker.get_intraday_bars.return_value["RECL"][0],
|
|
"atr": 2.0,
|
|
"rvol": 5.0,
|
|
"gap_pct": 0.03,
|
|
"score": 0.95,
|
|
"body_ratio": 0.5,
|
|
"close_location": 0.8,
|
|
"premarket_dollar_vol": 5_000_000,
|
|
}
|
|
]
|
|
eng._pending_cands = list(eng._candidates)
|
|
eng._now_et = lambda: dt.datetime(2026, 1, 5, 10, 5, tzinfo=_ET) # type: ignore[method-assign]
|
|
return eng
|
|
|
|
|
|
@patch("apps.orb_trader.engine.time")
|
|
@patch("apps.orb_trader.engine.get_snapshots")
|
|
def test_stop_check_polls_live_nofill_vwap_reclaim(mock_snapshots, mock_time):
|
|
mock_snapshots.return_value = {"RECL": SimpleNamespace(price=103.0)}
|
|
eng = _make_engine()
|
|
|
|
result = eng.run_stop_check(_DATE)
|
|
|
|
assert result["reclaim_filled"] == 1
|
|
eng._broker.submit_market_buy.assert_called_once()
|
|
pos = eng._state.save_position.call_args.args[0]
|
|
assert pos.ticker == "RECL"
|
|
assert pos.trigger_type == "vwap_reclaim"
|
|
assert pos.shares > 0
|
|
eng._state.update_candidate_status.assert_called_with(
|
|
"test-session", _DATE, "RECL", "filled"
|
|
)
|
|
|
|
|
|
@patch("apps.orb_trader.engine.time")
|
|
@patch("apps.orb_trader.engine.get_snapshots")
|
|
def test_stop_check_rebuilds_reclaim_candidate_from_persisted_metadata(
|
|
mock_snapshots,
|
|
mock_time,
|
|
):
|
|
mock_snapshots.return_value = {"RECL": SimpleNamespace(price=103.0)}
|
|
eng = _make_engine()
|
|
saved_cand = dict(eng._candidates[0])
|
|
metadata = {
|
|
"orb_bar": saved_cand["orb_bar"],
|
|
"body_ratio": saved_cand["body_ratio"],
|
|
"close_location": saved_cand["close_location"],
|
|
"premarket_dollar_vol": saved_cand["premarket_dollar_vol"],
|
|
"live_day_context": {
|
|
"day_size_scale": 1.0,
|
|
"soft_day_reason": None,
|
|
"market_orb_quality_max_trades": None,
|
|
"market_orb_quality_reason": None,
|
|
"market_thrust_breadth_override_active": False,
|
|
"market_thrust_index_breadth_override_active": False,
|
|
"market_thrust_opening_breadth_override_active": False,
|
|
},
|
|
}
|
|
eng._state.list_candidates.return_value = [
|
|
{
|
|
"status": "pending",
|
|
"ticker": "RECL",
|
|
"direction": "bullish",
|
|
"orb_high": saved_cand["orb_bar"]["high"],
|
|
"orb_low": saved_cand["orb_bar"]["low"],
|
|
"atr": saved_cand["atr"],
|
|
"rvol": saved_cand["rvol"],
|
|
"gap_pct": saved_cand["gap_pct"],
|
|
"composite_score": saved_cand["score"],
|
|
"size_scale": 1.0,
|
|
"metadata_json": json.dumps(metadata),
|
|
}
|
|
]
|
|
eng._candidates = []
|
|
eng._pending_cands = []
|
|
|
|
result = eng.run_stop_check(_DATE)
|
|
|
|
assert result["reclaim_filled"] == 1
|
|
assert eng._state.list_candidates.called
|
|
assert eng._candidates[0]["orb_bar"]["timestamp"] == "2026-01-05T09:30:00-05:00"
|
|
eng._broker.submit_market_buy.assert_called_once()
|
|
|
|
|
|
@patch("apps.orb_trader.engine.time")
|
|
@patch("apps.orb_trader.engine.get_snapshots")
|
|
def test_stop_check_polls_market_thrust_impulse_late_breakout(
|
|
mock_snapshots,
|
|
mock_time,
|
|
):
|
|
mock_snapshots.return_value = {"RECL": SimpleNamespace(price=103.0)}
|
|
eng = _make_engine()
|
|
eng._params.nofill_vwap_reclaim_enabled = False
|
|
eng._params.soft_day_vwap_reclaim_enabled = False
|
|
eng._params.market_thrust_opening_impulse_reclaim_enabled = True
|
|
eng._params.market_thrust_opening_impulse_reclaim_entry_mode = "late_breakout"
|
|
eng._params.market_thrust_opening_impulse_reclaim_min_score_pct = 0.0
|
|
eng._params.market_thrust_opening_impulse_reclaim_max_trades = 1
|
|
eng._params.market_thrust_opening_impulse_reclaim_size_scale = 0.10
|
|
eng._params.market_thrust_opening_impulse_reclaim_only_when_no_primary_trades = False
|
|
eng._params.market_thrust_opening_impulse_reclaim_no_thrust_max_trades = None
|
|
eng._params.market_thrust_opening_impulse_reclaim_no_thrust_size_scale = None
|
|
eng._params.late_breakout_window_start_min = 30
|
|
eng._params.late_breakout_window_end_min = 150
|
|
eng._params.late_breakout_min_clearance_pct = 0.0
|
|
eng._params.late_breakout_confirm_rel_vol = None
|
|
eng._params.late_breakout_require_vwap_confirmation = False
|
|
eng._candidates[0]["market_thrust_opening_impulse_reclaim"] = True
|
|
eng._pending_cands = list(eng._candidates)
|
|
|
|
result = eng.run_stop_check(_DATE)
|
|
|
|
assert result["reclaim_filled"] == 1
|
|
eng._broker.submit_market_buy.assert_called_once()
|
|
pos = eng._state.save_position.call_args.args[0]
|
|
assert pos.ticker == "RECL"
|
|
assert pos.trigger_type == "market_thrust_opening_impulse_reclaim"
|
|
eng._state.update_candidate_status.assert_called_with(
|
|
"test-session", _DATE, "RECL", "filled"
|
|
)
|
|
|
|
|
|
@patch("apps.orb_trader.engine.time")
|
|
@patch("apps.orb_trader.engine.get_snapshots")
|
|
def test_market_thrust_impulse_no_primary_allows_auxiliary_prior_trade(
|
|
mock_snapshots,
|
|
mock_time,
|
|
):
|
|
mock_snapshots.return_value = {"RECL": SimpleNamespace(price=103.0)}
|
|
eng = _make_engine()
|
|
eng._state.list_trades.return_value = [
|
|
{
|
|
"date": _DATE,
|
|
"ticker": "AUX",
|
|
"trigger_type": "vwap_reclaim",
|
|
"pnl": 10.0,
|
|
"exit_reason": "close",
|
|
}
|
|
]
|
|
eng._params.nofill_vwap_reclaim_enabled = False
|
|
eng._params.soft_day_vwap_reclaim_enabled = False
|
|
eng._params.market_thrust_opening_impulse_reclaim_enabled = True
|
|
eng._params.market_thrust_opening_impulse_reclaim_entry_mode = "late_breakout"
|
|
eng._params.market_thrust_opening_impulse_reclaim_min_score_pct = 0.0
|
|
eng._params.market_thrust_opening_impulse_reclaim_max_trades = 1
|
|
eng._params.market_thrust_opening_impulse_reclaim_size_scale = 0.10
|
|
eng._params.market_thrust_opening_impulse_reclaim_only_when_no_primary_trades = True
|
|
eng._params.market_thrust_opening_impulse_reclaim_no_thrust_max_trades = None
|
|
eng._params.market_thrust_opening_impulse_reclaim_no_thrust_size_scale = None
|
|
eng._params.late_breakout_window_start_min = 30
|
|
eng._params.late_breakout_window_end_min = 150
|
|
eng._params.late_breakout_min_clearance_pct = 0.0
|
|
eng._params.late_breakout_confirm_rel_vol = None
|
|
eng._params.late_breakout_require_vwap_confirmation = False
|
|
eng._candidates[0]["market_thrust_opening_impulse_reclaim"] = True
|
|
eng._pending_cands = list(eng._candidates)
|
|
|
|
result = eng.run_stop_check(_DATE)
|
|
|
|
assert result["reclaim_filled"] == 1
|
|
pos = eng._state.save_position.call_args.args[0]
|
|
assert pos.trigger_type == "market_thrust_opening_impulse_reclaim"
|