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
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)
|