diff --git a/OPERATIONS.md b/OPERATIONS.md index 834f114..039aa43 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -145,6 +145,22 @@ python -m bot.profile_runner paper validate btc python -m bot.profile_runner live validate btc ``` +Validate actual paper broker and market-data access without placing an order: + +```bash +python -m bot.profile_runner paper connectivity btc +``` + +EC2 deployment runs this connectivity check before installing cron. Invalid or expired credentials therefore fail deployment instead of producing a validation-only database that looks healthy. + +The paper BTC cron installation creates three jobs by default: + +- trading cycle every 5 minutes +- monitor report hourly at minute 17 +- research replay daily at 00:42 ET + +Override these with `CRON_SCHEDULE`, `MONITOR_CRON_SCHEDULE`, and `RESEARCH_CRON_SCHEDULE` when installing cron. + Current runtime defaults: - entry stale-bar blocking is disabled unless `ENABLE_STALE_BAR_CHECK=1` diff --git a/README.md b/README.md index dea7c2e..4aebeea 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,14 @@ python -m bot.profile_runner paper trade spy python -m bot.profile_runner live trade spy ``` +Before relying on a schedule, verify both the paper account and market-data feed (this never places an order): + +```bash +python -m bot.profile_runner paper connectivity btc +``` + +The BTC paper profile is an exploration profile: it permits up to 8 entries per day with a one-bar cooldown and slightly looser ADX/volume gates, while reducing target notional to 25% and ATR risk sizing to 1%. Live BTC settings are unchanged. The EC2 paper schedule refreshes its monitor hourly and its historical research report daily. + ## Run ```bash diff --git a/bot/profile_runner.py b/bot/profile_runner.py index cee039f..0309fbc 100644 --- a/bot/profile_runner.py +++ b/bot/profile_runner.py @@ -6,7 +6,10 @@ def _usage() -> int: - print("Usage: python -m bot.profile_runner [spy|btc]") + print( + "Usage: python -m bot.profile_runner " + " [spy|btc]" + ) return 2 @@ -48,6 +51,11 @@ def main(argv: list[str] | None = None) -> int: return int(validate_main()) + if action == "connectivity": + from bot.validate_connectivity import main as connectivity_main + + return int(connectivity_main()) + return _usage() diff --git a/bot/report_monitor.py b/bot/report_monitor.py index 0811ebd..b2db158 100644 --- a/bot/report_monitor.py +++ b/bot/report_monitor.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import sqlite3 from datetime import datetime, timedelta, timezone @@ -191,12 +192,68 @@ def _latest_metric_lines(latest_run) -> list[str]: return [f"- Strategy metrics: {'; '.join(parts)}"] +def _runtime_health( + runs: pd.DataFrame, + max_age_minutes: int = 15, + now: datetime | None = None, +) -> dict: + checked_at = pd.Timestamp(now or datetime.now(timezone.utc)) + if checked_at.tzinfo is None: + checked_at = checked_at.tz_localize("UTC") + else: + checked_at = checked_at.tz_convert("UTC") + + empty_status = "no_real_runs" + if runs.empty or "ts" not in runs.columns: + return { + "healthy": False, + "status": empty_status, + "latest_real_run_ts": None, + "age_minutes": None, + "max_age_minutes": max_age_minutes, + } + + notes = runs.get("note", pd.Series("", index=runs.index)).fillna("").astype(str) + real_runs = runs[~notes.str.contains("runtime_validation_sample", regex=False)] + if real_runs.empty: + return { + "healthy": False, + "status": "validation_only", + "latest_real_run_ts": None, + "age_minutes": None, + "max_age_minutes": max_age_minutes, + } + + latest_ts = pd.to_datetime(real_runs["ts"], utc=True, errors="coerce").dropna().max() + if pd.isna(latest_ts): + return { + "healthy": False, + "status": empty_status, + "latest_real_run_ts": None, + "age_minutes": None, + "max_age_minutes": max_age_minutes, + } + + age_minutes = max(0.0, (checked_at - latest_ts).total_seconds() / 60.0) + healthy = age_minutes <= max_age_minutes + return { + "healthy": healthy, + "status": "healthy" if healthy else "stale", + "latest_real_run_ts": latest_ts, + "age_minutes": age_minutes, + "max_age_minutes": max_age_minutes, + } + + def _near_miss_rows(runs: pd.DataFrame, limit: int = 10) -> list[dict]: if runs.empty: return [] near_misses: list[dict] = [] for row in runs.tail(500).itertuples(): + note = str(getattr(row, "note", "") or "") + if "runtime_validation_sample" in note: + continue signal = str(getattr(row, "signal", "") or "") action = str(getattr(row, "desired_action", "") or "") try: @@ -290,6 +347,10 @@ def main() -> None: closed = add_condition_buckets(closed) latest_run = runs.iloc[-1] if not runs.empty else None + runtime_health = _runtime_health( + runs, + max_age_minutes=max(1, int(os.getenv("HEALTH_MAX_RUN_AGE_MINUTES", "15"))), + ) latest_events = events.tail(20) if not events.empty else pd.DataFrame() pending_orders = orders[orders["processed_at"].isna()] if not orders.empty and "processed_at" in orders.columns else pd.DataFrame() summary = closed_trade_summary(closed) @@ -382,6 +443,12 @@ def main() -> None: ) latest_run_lines.extend(_latest_metric_lines(latest_run)) + health_lines = [ + f"- Status: {'HEALTHY' if runtime_health['healthy'] else 'UNHEALTHY'} ({runtime_health['status']})", + f"- Latest real trading cycle: {_fmt_ts(runtime_health['latest_real_run_ts'])}", + f"- Cycle age: {_fmt_num(runtime_health['age_minutes'], 1)} minutes (limit {runtime_health['max_age_minutes']})", + ] + profit_factor_text = "n/a" if summary["profit_factor"] is None else f"{summary['profit_factor']:.2f}" lines = [ "# Monitor Report", @@ -391,12 +458,8 @@ def main() -> None: "## Latest Run", ] lines.extend(latest_run_lines if latest_run_lines else ["- No runs recorded yet."]) - lines.extend( - [ - "", - "## Bot State", - ] - ) + lines.extend(["", "## Runtime Health", *health_lines]) + lines.extend(["", "## Bot State"]) lines.extend(state_lines if state_lines else ["- No state row found yet."]) lines.extend( [ @@ -475,6 +538,7 @@ def main() -> None: "latest_run": None if latest_run is None else latest_run.to_dict(), "state": [] if state.empty else state.to_dict(orient="records"), "positions": [] if positions.empty else positions.to_dict(orient="records"), + "runtime_health": runtime_health, "pending_orders": [] if pending_orders.empty else pending_orders.to_dict(orient="records"), "rejection_counts": rejection_counts, "near_misses": near_misses, diff --git a/bot/validate_connectivity.py b/bot/validate_connectivity.py new file mode 100644 index 0000000..8ba6362 --- /dev/null +++ b/bot/validate_connectivity.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass + +from bot.broker_alpaca import get_recent_bars, make_clients + + +@dataclass(frozen=True) +class ConnectivityResult: + symbol: str + paper: bool + equity: float + bar_count: int + latest_bar_ts: str + + +def _env_flag(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def validate_connectivity() -> ConnectivityResult: + """Verify broker authentication and market-data access without placing an order.""" + symbol = os.getenv("SYMBOL", "SPY").strip().upper() + timeframe_minutes = int(os.getenv("TIMEFRAME_MINUTES", "5")) + paper = _env_flag("ALPACA_PAPER", True) + + trading, data = make_clients() + try: + account = trading.get_account() + equity = float(account.equity) + except Exception as exc: + raise RuntimeError( + f"Alpaca {'paper' if paper else 'live'} trading authentication failed for {symbol}. " + "Refresh the profile credentials before installing or trusting the schedule." + ) from exc + + bars = get_recent_bars(data, symbol, timeframe_minutes, limit=3) + if bars.empty: + raise RuntimeError( + f"Alpaca market-data connectivity failed for {symbol}; no recent bars were returned. " + "Check the credentials, symbol, and data entitlement." + ) + + return ConnectivityResult( + symbol=symbol, + paper=paper, + equity=equity, + bar_count=len(bars), + latest_bar_ts=str(bars.index[-1]), + ) + + +def main() -> int: + try: + result = validate_connectivity() + except RuntimeError as exc: + print(f"connectivity failed: {exc}", file=sys.stderr) + return 1 + mode = "paper" if result.paper else "live" + print( + f"connectivity ok: mode={mode} symbol={result.symbol} equity={result.equity:.2f} " + f"bars={result.bar_count} latest_bar={result.latest_bar_ts}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config/paper_btc.env b/config/paper_btc.env index ad30c8d..c3bfeb4 100644 --- a/config/paper_btc.env +++ b/config/paper_btc.env @@ -1,19 +1,11 @@ BOT_PROFILE=paper +STRATEGY_VERSION=v3-paper-btc-exploration +TIMEFRAME_MINUTES=5 SYMBOL=BTC/USD IS_CRYPTO=true ALPACA_PAPER=true -RESEARCH_STARTING_EQUITY=150 +RESEARCH_STARTING_EQUITY=250 RESEARCH_OUTPUT_STEM=research_paper_btc -STRATEGY_VERSION=v3-btc-defensive-trend - -# DEFENSIVE profile. Evidence (docs/strategy_revamp_2026-07.md): on a ~$150 -# account, Alpaca crypto friction (~0.25% taker + spread per side) exceeds any -# repeatable intraday edge. The old 5m scalp stack fired twice in 120 days and -# every "more active" variant lost money in replay — activity itself is the -# cost. This config only trades textbook 4h uptrends with wide trails: -# it took ZERO trades in the 2025-26 bear replay (BTC -46%) and was mildly -# positive in the 2024-25 bull replay. Prefer the equity profile for growth. -TIMEFRAME_MINUTES=60 # 24/7 crypto — no market-hours session management ALLOW_OVERNIGHT_HOLDING=true @@ -23,69 +15,87 @@ LONG_ENTRY_WINDOWS=0000-2359 SHORT_ENTRY_WINDOWS=0000-2359 ALLOW_SHORTS=false -# Near-full-notional sizing: $45 positions can never outrun fees. One position, -# ~90% of equity, stop ~2.5 hourly ATRs (~2-3% of position). -POSITION_SIZING_MODE=notional_cap -MAX_POSITION_NOTIONAL_PCT=0.95 -TARGET_POSITION_NOTIONAL_PCT=0.90 -ATR_RISK_PER_TRADE_PCT=0.0075 +# Fractional BTC sizing — ATR-risk formula capped by notional limit +POSITION_SIZING_MODE=atr_risk +ATR_RISK_PER_TRADE_PCT=0.01 +MAX_POSITION_NOTIONAL_PCT=0.25 +TARGET_POSITION_NOTIONAL_PCT=0.25 MIN_ORDER_NOTIONAL=1.0 -# Exits: wide trail so the rare winner runs for days; that is the only way a -# trade can clear ~0.6% round-trip friction with room to spare. +# Exit management HARD_STOP_ATR_MULT=2.5 -TRAIL_AFTER_ATR_MULTIPLE=3.0 -TRAIL_ATR_MULTIPLIER=3.0 -MAX_BARS_IN_TRADE=999 +TRAIL_AFTER_ATR_MULTIPLE=1.5 +TRAIL_ATR_MULTIPLIER=2.0 +MAX_BARS_IN_TRADE=18 EXIT_ON_REGIME_INVALIDATION=true -ENABLE_BREAKEVEN_STOP=false +ENABLE_BREAKEVEN_STOP=true BREAKEVEN_AFTER_ATR_MULTIPLE=1.0 ENABLE_PROFIT_LOCK=false PROFIT_LOCK_AFTER_ATR_MULTIPLE=2.0 PROFIT_LOCK_ATR_MULTIPLE=0.5 -# Core signal on hourly bars -SMA_FAST=20 -SMA_SLOW=50 +# --- CORE SIGNAL FILTERS --- +# SMA trend direction: fast > slow handled by strategy logic + +# ADX: trend must have some strength, but not as demanding as live config +ADX_THRESHOLD=16 +LONG_ADX_THRESHOLD=16 + +# ATR% cap: raised to 2.5% to accommodate BTC's normal volatility range +ATR_MAX_PCT=0.025 +LONG_ATR_MAX_PCT=0.025 + +# Volume: light minimum — just needs to be active +MIN_VOLUME_RATIO=0.8 +VOLUME_MIN_MULTIPLIER=0.8 + +# Trend EMA: keep as directional filter (price > EMA55 and SMA20 >= EMA55) +# but remove the distance minimum so it doesn't over-filter EXEC_TREND_EMA_PERIOD=55 -ADX_THRESHOLD=20 -LONG_ADX_THRESHOLD=20 -ATR_MAX_PCT=0.03 -LONG_ATR_MAX_PCT=0.03 LONG_MIN_TREND_EMA_DISTANCE_PCT=0 SHORT_MIN_TREND_EMA_DISTANCE_PCT=0 -# Micro-filters off — replay showed they only added noise. Alpaca BTC volume -# data is too thin (many bars trade 0.0001 BTC) for volume filters to mean anything. -MIN_VOLUME_RATIO=0 -VOLUME_MIN_MULTIPLIER=0 +# Regime: BTC above its 4-day hourly EMA — slope filter off (too tight for slow EMA) +REGIME_TIMEFRAME_MINUTES=60 +REGIME_EMA_PERIOD=96 +REGIME_ADX_PERIOD=14 +REGIME_ADX_MIN=0 +REGIME_SLOPE_LOOKBACK_BARS=3 +REGIME_MIN_SLOPE_PCT=0 +REGIME_ATR_MAX_PCT=1.0 + +# --- DISABLED FILTERS (re-enable one at a time after baseline is established) --- + +# Momentum: off MOMENTUM_LOOKBACK_BARS=0 + +# SMA spread minimum: off MIN_SMA_SPREAD_ATR_MULT=0 + +# ADX must be accelerating: off MIN_ADX_DELTA=0 + +# Pullback depth window: fully open (0 to 99 ATR) PULLBACK_MIN_DEPTH_ATR=0 PULLBACK_MAX_DEPTH_ATR=99 + +# Reaccel bar: just requires a bullish close above prior close (body threshold = 0) REACCEL_MIN_BAR_BODY_ATR=0 -SPIKE_BAR_MAX_RANGE_ATR=10 -# Regime gate: 4h EMA(120) must be rising >=0.8% over 24h. This is the switch -# that kept the strategy flat through the entire 2025-26 bear. -REGIME_TIMEFRAME_MINUTES=240 -REGIME_EMA_PERIOD=120 -REGIME_ADX_PERIOD=14 -REGIME_ADX_MIN=0 -REGIME_SLOPE_LOOKBACK_BARS=6 -REGIME_MIN_SLOPE_PCT=0.008 -REGIME_ATR_MAX_PCT=1.0 +# Spike bar filter: very permissive +SPIKE_BAR_MAX_RANGE_ATR=10 -# Daily risk limits for roughly $150 live capital -MAX_DAILY_DRAWDOWN_PCT=0.04 -MAX_DAILY_LOSS=6 -MAX_CONSECUTIVE_LOSSES=3 -MAX_TRADES_PER_DAY=2 -MAX_CONSECUTIVE_ENTRY_FAILURES_PER_DAY=3 -COOLDOWN_BARS=4 -REENTRY_REQUIRES_SIGNAL_STRENGTH_IMPROVEMENT=false +# --- RISK LIMITS --- +MAX_DAILY_DRAWDOWN_PCT=0.05 +MAX_DAILY_LOSS=4 +MAX_CONSECUTIVE_LOSSES=2 +MAX_TRADES_PER_DAY=8 +MAX_CONSECUTIVE_ENTRY_FAILURES_PER_DAY=2 -# Hourly bars: allow up to 1.5 bars of age before calling data stale. +# Data quality ENABLE_STALE_BAR_CHECK=true -MAX_BAR_AGE_SECONDS=5400 +MAX_BAR_AGE_SECONDS=600 + +# Entry throttles +COOLDOWN_BARS=1 +REENTRY_REQUIRES_SIGNAL_STRENGTH_IMPROVEMENT=false diff --git a/deploy/ec2/deploy_remote.sh b/deploy/ec2/deploy_remote.sh index bcc42e2..da85c55 100644 --- a/deploy/ec2/deploy_remote.sh +++ b/deploy/ec2/deploy_remote.sh @@ -42,6 +42,9 @@ echo "Building trading-bot image in $app_dir" echo "Running ${profile} validation for ${market}" "$docker_bin" compose run --rm --entrypoint python "$compose_service" -m bot.profile_runner "$profile" validate "$market" +echo "Checking ${profile} broker and market-data connectivity for ${market}" +"$docker_bin" compose run --rm --entrypoint python "$compose_service" -m bot.profile_runner "$profile" connectivity "$market" + if [[ "$install_cron" == "true" || "$install_cron" == "1" ]]; then echo "Installing cron schedule for ${profile}" bash deploy/ec2/install_cron.sh "$profile" "$app_dir" diff --git a/deploy/ec2/install_cron.sh b/deploy/ec2/install_cron.sh index 5e17c71..b87b1dc 100644 --- a/deploy/ec2/install_cron.sh +++ b/deploy/ec2/install_cron.sh @@ -5,8 +5,11 @@ set -Eeuo pipefail profile="${1:-live}" app_dir="${2:-${APP_DIR:-/opt/trading-bot/app}}" cron_tz="${CRON_TZ:-America/New_York}" -schedule="${CRON_SCHEDULE:-5 * * * *}" +schedule="${CRON_SCHEDULE:-*/5 * * * *}" +monitor_schedule="${MONITOR_CRON_SCHEDULE:-17 * * * *}" +research_schedule="${RESEARCH_CRON_SCHEDULE:-42 0 * * *}" docker_bin="${DOCKER_BIN:-$(command -v docker || true)}" +market="${DEPLOY_MARKET:-btc}" job_marker="# trading-bot-${profile}" case "$profile" in @@ -29,13 +32,22 @@ fi mkdir -p "$app_dir/logs" -job_line="${schedule} cd ${app_dir} && ${docker_bin} compose run --rm ${compose_service} >> ${app_dir}/logs/${profile}_cron.log 2>&1 ${job_marker}" +trade_job="${schedule} cd ${app_dir} && ${docker_bin} compose run --rm ${compose_service} >> ${app_dir}/logs/${profile}_cron.log 2>&1 ${job_marker}" +monitor_job="${monitor_schedule} cd ${app_dir} && ${docker_bin} compose run --rm --entrypoint python ${compose_service} -m bot.profile_runner ${profile} monitor ${market} >> ${app_dir}/logs/${profile}_monitor_cron.log 2>&1 ${job_marker}" current_crontab="$(crontab -l 2>/dev/null || true)" { echo "CRON_TZ=${cron_tz}" printf '%s\n' "$current_crontab" | grep -v '^CRON_TZ=' | grep -v -F "$job_marker" || true - echo "$job_line" + echo "$trade_job" + echo "$monitor_job" + if [[ "$profile" == "paper" ]]; then + echo "${research_schedule} cd ${app_dir} && ${docker_bin} compose run --rm --entrypoint python ${compose_service} -m bot.profile_runner ${profile} research ${market} >> ${app_dir}/logs/${profile}_research_cron.log 2>&1 ${job_marker}" + fi } | sed '/^[[:space:]]*$/d' | crontab - -echo "Installed cron entry for ${profile}: ${schedule} (${cron_tz})" +echo "Installed ${profile} trade schedule: ${schedule} (${cron_tz})" +echo "Installed ${profile} monitor schedule: ${monitor_schedule} (${cron_tz})" +if [[ "$profile" == "paper" ]]; then + echo "Installed ${profile} research schedule: ${research_schedule} (${cron_tz})" +fi diff --git a/tests/test_profile.py b/tests/test_profile.py index 34b8c3a..83ba9de 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -57,6 +57,11 @@ def test_paper_btc_profile_enables_crypto_defaults(self): self.assertEqual(os.environ["IS_CRYPTO"], "true") self.assertEqual(os.environ["ALLOW_OVERNIGHT_HOLDING"], "true") self.assertEqual(os.environ["FLATTEN_BEFORE_CLOSE_MINUTES"], "0") + self.assertEqual(os.environ["STRATEGY_VERSION"], "v3-paper-btc-exploration") + self.assertEqual(os.environ["MAX_TRADES_PER_DAY"], "8") + self.assertEqual(os.environ["COOLDOWN_BARS"], "1") + self.assertEqual(os.environ["ATR_RISK_PER_TRADE_PCT"], "0.01") + self.assertEqual(os.environ["TARGET_POSITION_NOTIONAL_PCT"], "0.25") self.assertIn("paper_btc", os.environ["BOT_DATA_DIR"]) finally: os.environ.clear() diff --git a/tests/test_report_monitor.py b/tests/test_report_monitor.py index bb474c5..6c6127c 100644 --- a/tests/test_report_monitor.py +++ b/tests/test_report_monitor.py @@ -4,10 +4,34 @@ import pandas as pd -from bot.report_monitor import _latest_metric_lines, _near_miss_rows, _reason_count_sections +from bot.report_monitor import _latest_metric_lines, _near_miss_rows, _reason_count_sections, _runtime_health class ReportMonitorTests(unittest.TestCase): + def test_runtime_health_ignores_validation_samples_and_flags_stale_cycles(self): + now = datetime.now(timezone.utc) + runs = pd.DataFrame( + [ + {"ts": pd.Timestamp(now - timedelta(minutes=30)), "note": ""}, + {"ts": pd.Timestamp(now), "note": "runtime_validation_sample"}, + ] + ) + + health = _runtime_health(runs, max_age_minutes=15, now=now) + + self.assertFalse(health["healthy"]) + self.assertEqual(health["status"], "stale") + self.assertAlmostEqual(health["age_minutes"], 30.0) + + def test_runtime_health_flags_validation_only_database(self): + now = datetime.now(timezone.utc) + runs = pd.DataFrame([{"ts": pd.Timestamp(now), "note": "runtime_validation_sample"}]) + + health = _runtime_health(runs, now=now) + + self.assertFalse(health["healthy"]) + self.assertEqual(health["status"], "validation_only") + def test_reason_count_sections_counts_24h_and_7d_rejections(self): now = datetime.now(timezone.utc) runs = pd.DataFrame( @@ -58,6 +82,16 @@ def test_near_miss_rows_include_one_or_two_true_blockers(self): "note": "", "metrics_json": json.dumps(metrics), }, + { + "ts": now, + "signal": "HOLD", + "desired_action": "HOLD", + "position_qty": 0.0, + "price": 100.0, + "reasons": "indicators_not_ready", + "note": "runtime_validation_sample", + "metrics_json": "{}", + }, ] ) diff --git a/tests/test_validate_connectivity.py b/tests/test_validate_connectivity.py new file mode 100644 index 0000000..9fb9c5f --- /dev/null +++ b/tests/test_validate_connectivity.py @@ -0,0 +1,47 @@ +import os +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pandas as pd + +from bot.validate_connectivity import validate_connectivity + + +class ValidateConnectivityTests(unittest.TestCase): + def test_validates_account_and_recent_market_data_without_orders(self): + trading = Mock() + trading.get_account.return_value = SimpleNamespace(equity="250.50") + data = Mock() + bars = pd.DataFrame( + {"close": [100.0, 101.0, 102.0]}, + index=pd.date_range("2026-07-20T00:00:00Z", periods=3, freq="5min"), + ) + + with patch.dict( + os.environ, + {"SYMBOL": "BTC/USD", "TIMEFRAME_MINUTES": "5", "ALPACA_PAPER": "true"}, + clear=False, + ), patch("bot.validate_connectivity.make_clients", return_value=(trading, data)), patch( + "bot.validate_connectivity.get_recent_bars", return_value=bars + ): + result = validate_connectivity() + + self.assertTrue(result.paper) + self.assertEqual(result.bar_count, 3) + self.assertEqual(result.equity, 250.5) + trading.submit_order.assert_not_called() + + def test_rejects_empty_market_data(self): + trading = Mock() + trading.get_account.return_value = SimpleNamespace(equity="250") + + with patch.dict(os.environ, {"SYMBOL": "BTC/USD"}, clear=False), patch( + "bot.validate_connectivity.make_clients", return_value=(trading, Mock()) + ), patch("bot.validate_connectivity.get_recent_bars", return_value=pd.DataFrame()): + with self.assertRaisesRegex(RuntimeError, "market-data connectivity failed"): + validate_connectivity() + + +if __name__ == "__main__": + unittest.main()