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:
1 parent
13c4b61e77
commit
8c62804808
6 files changed
+124
-17
No files matched your search
+9
-7
@@ -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())
|
||||
# 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"),
|
||||
"mode": ("LIVE" if "paper" not in
|
||||
os.environ.get("ALPACA_BASE_URL", "paper")
|
||||
else "paper")}
|
||||
"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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user