feat: broker-aware factory for reconcile/journal/health tools

eod_reconcile, backfill_journal and the admin health block were hardwired
to AlpacaPaperClient — on a BROKER=tradier stack they would silently
reconcile/report against the wrong broker. make_trading_client() honors
BROKER env with no silent cross-broker fallback (missing Tradier keys =>
skip with reason, never fall back to Alpaca). Admin UI shows the active
broker label; JSON key stays 'alpaca' for compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-27 13:13:01 -05:00
1 parent 13c4b61e77
commit 8c62804808
6 files changed
+127 -20

No files matched your search

+12 -10
View File
@@ -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:
+1 -1
View File
@@ -111,7 +111,7 @@ async function load(){
const c=s.checks||{};
$('#health').innerHTML=[
`<div class="card row">${dot(c.db?.ok)}<div><b>Database</b><div class="mut">${c.db?.ok?`event ${c.db.newest_event_age_s}s ago`:c.db?.error||''}</div></div></div>`,
`<div class="card row">${dot(c.alpaca?.ok)}<div><b>Alpaca</b><div class="mut">${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}</div></div></div>`,
`<div class="card row">${dot(c.alpaca?.ok)}<div><b>${c.alpaca?.name||'Broker'}</b><div class="mut">${c.alpaca?.ok?`$${(+c.alpaca.equity).toLocaleString()} · market ${c.alpaca.market_open?'open':'closed'}`:c.alpaca?.error||''}</div></div></div>`,
`<div class="card row">${dot(c.ollama?.ok)}<div><b>Ollama</b><div class="mut">${c.ollama?.ok?'enrichment up':'down (fails safe)'}</div></div></div>`,
`<div class="card row">${dot(c.feed==='sip')}<div><b>Data feed</b><div class="mut">${c.feed}</div></div></div>`].join('');
$('#mode').className='badge '+(c.alpaca?.mode==='LIVE'?'live':'paper');
+5 -3
View File
@@ -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")
+7 -6
View File
@@ -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
+56
View File
@@ -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()
+46
View File
@@ -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