diff --git a/admin_api.py b/admin_api.py index b1725e4..d1e746a 100644 --- a/admin_api.py +++ b/admin_api.py @@ -244,16 +244,18 @@ def checks() -> dict: except Exception as exc: # noqa: BLE001 out["db"] = {"ok": False, "error": str(exc)[:120]} try: - from alpaca_paper.client import AlpacaPaperClient - from alpaca_paper.settings import AlpacaPaperSettings - c = AlpacaPaperClient(AlpacaPaperSettings.from_env()) - clock = c.get_clock() - acct = c.get_account() - out["alpaca"] = {"ok": True, "market_open": clock.get("is_open"), - "equity": acct.get("equity"), - "mode": ("LIVE" if "paper" not in - os.environ.get("ALPACA_BASE_URL", "paper") - else "paper")} + # active execution broker (BROKER env) — JSON key stays "alpaca" for + # UI compatibility; "name" carries the real broker label + from tradier_broker.factory import make_trading_client + c, label = make_trading_client() + if c is None: + out["alpaca"] = {"ok": False, "error": label} + else: + clock = c.get_clock() + acct = c.get_account() + out["alpaca"] = {"ok": True, "market_open": clock.get("is_open"), + "equity": acct.get("equity"), "name": label, + "mode": "LIVE" if label.endswith("LIVE") else "paper"} except Exception as exc: # noqa: BLE001 out["alpaca"] = {"ok": False, "error": str(exc)[:120]} try: diff --git a/api/static/admin.html b/api/static/admin.html index 054f54c..4840220 100644 --- a/api/static/admin.html +++ b/api/static/admin.html @@ -111,7 +111,7 @@ async function load(){ const c=s.checks||{}; $('#health').innerHTML=[ `
${dot(c.db?.ok)}
Database
${c.db?.ok?`event ${c.db.newest_event_age_s}s ago`:c.db?.error||''}
`, - `
${dot(c.alpaca?.ok)}
Alpaca
${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}
`, + `
${dot(c.alpaca?.ok)}
${c.alpaca?.name||'Broker'}
${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}
`, `
${dot(c.ollama?.ok)}
Ollama
${c.ollama?.ok?'enrichment up':'down (fails safe)'}
`, `
${dot(c.feed==='sip')}
Data feed
${c.feed}
`].join(''); $('#mode').className='badge '+(c.alpaca?.mode==='LIVE'?'live':'paper'); diff --git a/backfill_journal.py b/backfill_journal.py index 3daefe2..8bd3d11 100644 --- a/backfill_journal.py +++ b/backfill_journal.py @@ -45,16 +45,18 @@ def main() -> None: args = ap.parse_args() load_dotenv() - from alpaca_paper.client import AlpacaPaperClient from storage.event_schema import EventMode, PositionClosedEvent from storage.event_store import EventStore from storage.journal_backfill import flatten_fills, reconstruct_round_trips + from tradier_broker.factory import make_trading_client store = EventStore("momentum") - client = AlpacaPaperClient() + client, label = make_trading_client() + if client is None: + raise SystemExit(f"[backfill_journal] {label} — aborting") raw = client.get_orders(status="closed", limit=500, nested=True) - print(f"fetched {len(raw)} historical orders from Alpaca") + print(f"fetched {len(raw)} historical orders from {label}") trips = reconstruct_round_trips(flatten_fills(raw)) print(f"reconstructed {len(trips)} completed round-trips") diff --git a/research/eod_reconcile.py b/research/eod_reconcile.py index dcb407d..8989034 100644 --- a/research/eod_reconcile.py +++ b/research/eod_reconcile.py @@ -119,15 +119,16 @@ def main() -> int: from runtime.env_overrides import load as _load_overrides _load_overrides() - from alpaca_paper.client import AlpacaPaperClient - from alpaca_paper.settings import AlpacaPaperSettings from storage.event_store import EventStore + from tradier_broker.factory import make_trading_client - settings = AlpacaPaperSettings.from_env() - if not settings.is_configured: - print("[eod_reconcile] no Alpaca keys — skipping", flush=True) + # reconcile against the ACTIVE execution broker (BROKER env), never a + # hardwired one — drift-checking the wrong broker is silently meaningless + client, label = make_trading_client() + if client is None: + print(f"[eod_reconcile] {label} — skipping", flush=True) return 0 - client = AlpacaPaperClient(settings) + print(f"[eod_reconcile] reconciling against {label}", flush=True) store = EventStore(os.environ.get("WATCHER_EVENT_DB_PATH", "momentum")) try: from runtime.notifier import send_telegram diff --git a/tests/test_broker_factory.py b/tests/test_broker_factory.py new file mode 100644 index 0000000..dbd251b --- /dev/null +++ b/tests/test_broker_factory.py @@ -0,0 +1,56 @@ +"""make_trading_client honors BROKER env with no silent cross-broker fallback.""" + +import unittest +from unittest.mock import patch + +from alpaca_paper.client import AlpacaPaperClient +from tradier_broker.client import TradierClient +from tradier_broker.factory import make_trading_client + +ALPACA = {"ALPACA_API_KEY": "k", "ALPACA_SECRET_KEY": "s"} +TRADIER = {"TRADIER_ACCESS_TOKEN": "t", "TRADIER_ACCOUNT_ID": "a"} + + +def _env(extra): + env = {"BROKER": "", "TRADIER_ACCESS_TOKEN": "", "TRADIER_ACCOUNT_ID": "", + "TRADIER_ENV": "", "ALPACA_API_KEY": "", "ALPACA_SECRET_KEY": "", + "APCA_API_KEY_ID": "", "APCA_API_SECRET_KEY": "", + "APCA_API_BASE_URL": ""} + env.update(extra) + return patch.dict("os.environ", env) + + +class FactoryTests(unittest.TestCase): + def test_default_is_alpaca(self): + with _env(ALPACA): + client, label = make_trading_client() + self.assertIsInstance(client, AlpacaPaperClient) + self.assertEqual(label, "alpaca-paper") + + def test_tradier_selected(self): + with _env({**ALPACA, **TRADIER, "BROKER": "tradier"}): + client, label = make_trading_client() + self.assertIsInstance(client, TradierClient) + self.assertEqual(label, "tradier-sandbox") + self.assertIsInstance(client.data_client, AlpacaPaperClient) + + def test_tradier_live_label(self): + with _env({**ALPACA, **TRADIER, "BROKER": "tradier", "TRADIER_ENV": "live"}): + _, label = make_trading_client() + self.assertEqual(label, "tradier-LIVE") + + def test_no_fallback_when_tradier_keys_missing(self): + with _env({**ALPACA, "BROKER": "tradier"}): + client, label = make_trading_client() + self.assertIsNone(client) + self.assertIn("TRADIER_ACCESS_TOKEN", label) + + def test_no_alpaca_keys(self): + with _env({}): + client, label = make_trading_client() + self.assertIsNone(client) + self.assertEqual(label, "no Alpaca keys") + + +if __name__ == "__main__": + unittest.main() diff --git a/tradier_broker/factory.py b/tradier_broker/factory.py new file mode 100644 index 0000000..d781baf --- /dev/null +++ b/tradier_broker/factory.py @@ -0,0 +1,46 @@ +"""Broker-aware trading-client factory. + +Single place that honors BROKER env for every standalone tool that talks to +the EXECUTION broker (eod_reconcile, backfill_journal, admin health). The +live loop wires the same logic inline in run_live_paper (with its own boot +messaging); both must agree on the BROKER=tradier contract. + +No silent cross-broker fallback: if BROKER=tradier but Tradier keys are +missing, callers get (None, reason) — reconciling or journaling against the +WRONG broker is worse than skipping. +""" + +from __future__ import annotations + +import os + +from alpaca_paper.client import AlpacaPaperClient +from alpaca_paper.settings import AlpacaPaperSettings +from tradier_broker.client import TradierClient +from tradier_broker.settings import TradierSettings + + +def make_trading_client(data_client: AlpacaPaperClient | None = None): + """Return (client, label) for the active execution broker, or + (None, reason) when its keys are missing. `label` is human-readable + ('alpaca-paper', 'tradier-sandbox', 'tradier-LIVE').""" + broker = os.environ.get("BROKER", "alpaca").strip().lower() + alpaca = AlpacaPaperSettings.from_env() + if data_client is None and alpaca.is_configured: + data_client = AlpacaPaperClient(alpaca) + + if broker == "tradier": + ts = TradierSettings.from_env() + if not ts.is_configured: + return None, ("BROKER=tradier but TRADIER_ACCESS_TOKEN/" + "TRADIER_ACCOUNT_ID missing") + label = "tradier-sandbox" if ts.is_sandbox else "tradier-LIVE" + return TradierClient(ts, data_client=data_client), label + + if not alpaca.is_configured: + return None, "no Alpaca keys" + # empty APCA_API_BASE_URL means the client falls back to the paper host — + # never label that LIVE + host = alpaca.trading_base_url or "paper" + label = "alpaca-paper" if "paper" in host else "alpaca-LIVE" + return (data_client or AlpacaPaperClient(alpaca)), label