From 6cb5ec6793707d4e32f13815591c3714c2c6e895 Mon Sep 17 00:00:00 2001 From: Utkarsh Pandey Date: Mon, 20 Jul 2026 11:14:54 -0400 Subject: [PATCH] Increase paper trading cadence and runtime visibility --- OPERATIONS.md | 16 ++++++ README.md | 8 +++ bot/profile_runner.py | 10 +++- bot/report_monitor.py | 76 ++++++++++++++++++++++++++--- bot/validate_connectivity.py | 73 +++++++++++++++++++++++++++ config/paper_btc.env | 19 ++++---- deploy/ec2/deploy_remote.sh | 3 ++ deploy/ec2/install_cron.sh | 18 +++++-- tests/test_profile.py | 5 ++ tests/test_report_monitor.py | 36 +++++++++++++- tests/test_validate_connectivity.py | 47 ++++++++++++++++++ 11 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 bot/validate_connectivity.py create mode 100644 tests/test_validate_connectivity.py diff --git a/OPERATIONS.md b/OPERATIONS.md index 4a74a2e..3e93d17 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -133,6 +133,22 @@ python -m bot.profile_runner live validate btc python -m bot.profile_runner live validate spy ``` +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 db2c560..94cb364 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ python -m bot.profile_runner paper trade btc python -m bot.profile_runner live trade btc ``` +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 005ede4..6f215c5 100644 --- a/config/paper_btc.env +++ b/config/paper_btc.env @@ -1,4 +1,5 @@ BOT_PROFILE=paper +STRATEGY_VERSION=v3-paper-btc-exploration SYMBOL=BTC/USD IS_CRYPTO=true ALPACA_PAPER=true @@ -15,9 +16,9 @@ ALLOW_SHORTS=false # Fractional BTC sizing — ATR-risk formula capped by notional limit POSITION_SIZING_MODE=atr_risk -ATR_RISK_PER_TRADE_PCT=0.02 -MAX_POSITION_NOTIONAL_PCT=0.50 -TARGET_POSITION_NOTIONAL_PCT=0.50 +ATR_RISK_PER_TRADE_PCT=0.01 +MAX_POSITION_NOTIONAL_PCT=0.25 +TARGET_POSITION_NOTIONAL_PCT=0.25 MIN_ORDER_NOTIONAL=1.0 # Exit management @@ -36,16 +37,16 @@ PROFIT_LOCK_ATR_MULTIPLE=0.5 # SMA trend direction: fast > slow handled by strategy logic # ADX: trend must have some strength, but not as demanding as live config -ADX_THRESHOLD=20 -LONG_ADX_THRESHOLD=20 +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=1.0 -VOLUME_MIN_MULTIPLIER=1.0 +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 @@ -87,7 +88,7 @@ SPIKE_BAR_MAX_RANGE_ATR=10 MAX_DAILY_DRAWDOWN_PCT=0.05 MAX_DAILY_LOSS=4 MAX_CONSECUTIVE_LOSSES=2 -MAX_TRADES_PER_DAY=4 +MAX_TRADES_PER_DAY=8 MAX_CONSECUTIVE_ENTRY_FAILURES_PER_DAY=2 # Data quality @@ -95,5 +96,5 @@ ENABLE_STALE_BAR_CHECK=true MAX_BAR_AGE_SECONDS=600 # Entry throttles -COOLDOWN_BARS=3 +COOLDOWN_BARS=1 REENTRY_REQUIRES_SIGNAL_STRENGTH_IMPROVEMENT=false diff --git a/deploy/ec2/deploy_remote.sh b/deploy/ec2/deploy_remote.sh index eb701c6..2e56abc 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 20e0216..b87b1dc 100644 --- a/deploy/ec2/install_cron.sh +++ b/deploy/ec2/install_cron.sh @@ -6,7 +6,10 @@ profile="${1:-live}" app_dir="${2:-${APP_DIR:-/opt/trading-bot/app}}" cron_tz="${CRON_TZ:-America/New_York}" 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 06b312a..c526b19 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()