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.

296 lines
10 KiB
Python

#!/usr/bin/env python3
"""Integration test for ORB paper trading system.
Runs all 5 daily phases against MockORBBroker (no Alpaca API calls).
Tests:
1. Multiple sessions with different budgets
2. Session creation + budget persistence in SQLite
3. ORB detection + candidate ranking (all 971 tickers at once)
4. Breakout check + position entry (Oracle snapshot mock)
5. Stop checks (sim_bar_minutes interval)
6. EOD exit + P&L recording
7. Post-close equity snapshot (compound returns)
Usage:
python scripts/test_orb_paper.py
"""
from __future__ import annotations
import os
import sys
import tempfile
import traceback
from pathlib import Path
from unittest.mock import patch
# ── Repo root on path ─────────────────────────────────────────────────────────
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
import datetime as dt
from zoneinfo import ZoneInfo
_ET = ZoneInfo("America/New_York")
# ── Minimal ORB config YAML ───────────────────────────────────────────────────
_TEST_CONFIG_YAML = """\
strategy_mode: orb
orb_strategy:
orb_minutes: 10
sim_bar_minutes: 90
entry_direction: long_only
order_timeout_minutes: 45
min_price: 5.0
min_avg_dollar_volume: 1000000
min_atr_14: 0.10
min_rvol: 0.1
max_candidates: 10
min_candidates_to_trade: 1
weight_rvol: 0.60
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.05
breakeven_at_r: 1.0
trailing_at_r: 10.0
trailing_stop_atr_multiplier: 0.3
risk_per_trade_pct: 0.01
max_position_pct: 0.25
daily_max_loss_pct: 0.05
max_stops_per_day: 5
exit_minutes_before_close: 5
universe:
source: midlarge
"""
# Thresholds are intentionally loose so mock data always passes screening.
# ── Helpers ───────────────────────────────────────────────────────────────────
SEP = "" * 60
def section(title: str) -> None:
print(f"\n{SEP}")
print(f" {title}")
print(SEP)
def ok(msg: str) -> None:
print(f"{msg}")
def fail(msg: str) -> None:
print(f"{msg}")
sys.exit(1)
def assert_eq(label: str, got: Any, expected: Any) -> None:
if got == expected:
ok(f"{label}: {got!r}")
else:
fail(f"{label}: expected {expected!r}, got {got!r}")
from typing import Any
# ── Setup ─────────────────────────────────────────────────────────────────────
def setup_test_env(tmp_dir: str) -> tuple[str, str]:
"""Write test config and return (config_path, db_path)."""
config_path = os.path.join(tmp_dir, "test_orb.yaml")
db_path = os.path.join(tmp_dir, "orb_test.db")
with open(config_path, "w") as f:
f.write(_TEST_CONFIG_YAML)
return config_path, db_path
# ── Phase runner ──────────────────────────────────────────────────────────────
def run_phases(
engine: Any,
broker: Any,
date_str: str,
session_name: str,
) -> dict[str, Any]:
"""Run all 5 engine phases and return phase results dict."""
from apps.orb_trader.mock_broker import make_mock_snapshots
tickers = broker._tickers
results: dict[str, Any] = {}
print(f" [{session_name}] Phase 1: orb_detection")
with patch("apps.orb_trader.engine.load_universe", return_value=tickers):
r = engine.run_orb_detection(date_str)
results["orb_detection"] = r
print(f"{r}")
print(f" [{session_name}] Phase 2: breakout_check")
mock_snaps = make_mock_snapshots(tickers, date_str, price_mult=1.05)
with patch("libs.oracle_client.alpaca.get_snapshots", return_value=mock_snaps):
r = engine.run_breakout_check(date_str)
results["breakout_check"] = r
print(f"{r}")
print(f" [{session_name}] Phase 3: stop_check")
r = engine.run_stop_check(date_str)
results["stop_check"] = r
print(f"{r}")
print(f" [{session_name}] Phase 4: eod_exit")
r = engine.run_eod_exit(date_str)
results["eod_exit"] = r
print(f"{r}")
print(f" [{session_name}] Phase 5: post_close")
r = engine.run_post_close(date_str)
results["post_close"] = r
print(f"{r}")
return results
# ── Main test ─────────────────────────────────────────────────────────────────
def main() -> None:
print("\nORB Paper Trading — Integration Test")
print("=" * 60)
with tempfile.TemporaryDirectory() as tmp_dir:
config_path, db_path = setup_test_env(tmp_dir)
# ── 1. Session creation + budget tracking ─────────────────────────────
section("1. Session creation + budget persistence")
from apps.orb_trader.state import ORBStateManager
state = ORBStateManager(db_path)
SESSIONS = [
("alpha_session", 10_000.0),
("beta_session", 25_000.0),
]
for name, capital in SESSIONS:
sid = state.create_session(name, config_path, capital)
ok(f"Created '{name}' (id={sid}, budget=${capital:,.0f})")
sessions = state.list_sessions()
assert_eq("session count", len(sessions), 2)
for s in sessions:
expected_budget = dict(SESSIONS)[s.session_name]
assert_eq(
f" {s.session_name}.initial_equity",
s.initial_equity,
expected_budget,
)
# ── 2. Duplicate session name rejected ────────────────────────────────
section("2. Duplicate name check")
try:
state.create_session("alpha_session", config_path, 999.0)
fail("Should have raised IntegrityError for duplicate name")
except Exception as exc:
ok(f"Duplicate rejected: {type(exc).__name__}")
# ── 3. Run all phases per session ─────────────────────────────────────
section("3. Full trading day simulation (2 sessions)")
# Use the most recent past trading day
today = dt.date.today()
# Find a weekday (Mon-Fri) that's in the past
test_date = today - dt.timedelta(days=1)
while test_date.weekday() >= 5:
test_date -= dt.timedelta(days=1)
date_str = test_date.isoformat()
print(f"\n Simulating trading day: {date_str}")
from apps.orb_trader.mock_broker import MockORBBroker
from apps.orb_trader.engine import make_orb_engine
all_results: dict[str, dict] = {}
for s in sessions:
print(f"\n {'' * 40}")
print(f" Session: {s.session_name} | Budget: ${s.initial_equity:,.0f}")
print(f" {'' * 40}")
broker = MockORBBroker(
initial_equity=s.initial_equity,
tickers=["AAPL", "NVDA", "MSFT"],
)
engine = make_orb_engine(s, db_path, broker_override=broker)
results = run_phases(engine, broker, date_str, s.session_name)
all_results[s.session_name] = results
# ── 4. Verify DB state after simulation ───────────────────────────────
section("4. Verify DB state")
for s in sessions:
trades = state.list_trades(s.session_id)
snapshots = state.list_snapshots(s.session_id)
equity = state.get_equity(s.session_id)
print(f"\n [{s.session_name}]")
ok(f"Trades recorded: {len(trades)}")
ok(f"Equity snapshots: {len(snapshots)}")
if snapshots:
final_eq = snapshots[-1]["equity"]
ok(f"Final equity: ${final_eq:,.2f} (initial: ${s.initial_equity:,.0f})")
else:
ok("No equity snapshot (no positions entered today — OK if no candidates)")
assert_eq("equity from get_equity()", equity, snapshots[-1]["equity"] if snapshots else None)
# ── 5. Verify budget independence between sessions ─────────────────────
section("5. Budget independence check")
eq_alpha = state.get_equity("alpha_session") or dict(SESSIONS)["alpha_session"]
eq_beta = state.get_equity("beta_session") or dict(SESSIONS)["beta_session"]
if eq_alpha != eq_beta:
ok(f"Sessions have independent equity: alpha=${eq_alpha:,.2f}, beta=${eq_beta:,.2f}")
else:
ok(f"Sessions both at ${eq_alpha:,.2f} (no trades today — budgets remain independent)")
# ── 6. Pause / resume / delete ────────────────────────────────────────
section("6. Session lifecycle (pause / resume / delete)")
alpha = state.get_session("alpha_session")
state.set_session_status(alpha.session_id, "paused")
refreshed = state.get_session("alpha_session")
assert_eq("status after pause", refreshed.status, "paused")
state.set_session_status(alpha.session_id, "active")
refreshed = state.get_session("alpha_session")
assert_eq("status after resume", refreshed.status, "active")
state.delete_session(alpha.session_id)
deleted = state.get_session("alpha_session")
assert_eq("session after delete", deleted, None)
ok("alpha_session deleted")
remaining = state.list_sessions()
assert_eq("remaining sessions", len(remaining), 1)
assert_eq("remaining session name", remaining[0].session_name, "beta_session")
print(f"\n{'=' * 60}")
print(" ALL TESTS PASSED")
print(f"{'=' * 60}\n")
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception:
traceback.print_exc()
sys.exit(1)