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
+127
-20
No files matched your search
+12
-10
@@ -244,16 +244,18 @@ def checks() -> dict:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
out["db"] = {"ok": False, "error": str(exc)[:120]}
|
out["db"] = {"ok": False, "error": str(exc)[:120]}
|
||||||
try:
|
try:
|
||||||
from alpaca_paper.client import AlpacaPaperClient
|
# active execution broker (BROKER env) — JSON key stays "alpaca" for
|
||||||
from alpaca_paper.settings import AlpacaPaperSettings
|
# UI compatibility; "name" carries the real broker label
|
||||||
c = AlpacaPaperClient(AlpacaPaperSettings.from_env())
|
from tradier_broker.factory import make_trading_client
|
||||||
clock = c.get_clock()
|
c, label = make_trading_client()
|
||||||
acct = c.get_account()
|
if c is None:
|
||||||
out["alpaca"] = {"ok": True, "market_open": clock.get("is_open"),
|
out["alpaca"] = {"ok": False, "error": label}
|
||||||
"equity": acct.get("equity"),
|
else:
|
||||||
"mode": ("LIVE" if "paper" not in
|
clock = c.get_clock()
|
||||||
os.environ.get("ALPACA_BASE_URL", "paper")
|
acct = c.get_account()
|
||||||
else "paper")}
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
out["alpaca"] = {"ok": False, "error": str(exc)[:120]}
|
out["alpaca"] = {"ok": False, "error": str(exc)[:120]}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async function load(){
|
|||||||
const c=s.checks||{};
|
const c=s.checks||{};
|
||||||
$('#health').innerHTML=[
|
$('#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.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.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('');
|
`<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');
|
$('#mode').className='badge '+(c.alpaca?.mode==='LIVE'?'live':'paper');
|
||||||
|
|||||||
+5
-3
@@ -45,16 +45,18 @@ def main() -> None:
|
|||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
from alpaca_paper.client import AlpacaPaperClient
|
|
||||||
from storage.event_schema import EventMode, PositionClosedEvent
|
from storage.event_schema import EventMode, PositionClosedEvent
|
||||||
from storage.event_store import EventStore
|
from storage.event_store import EventStore
|
||||||
from storage.journal_backfill import flatten_fills, reconstruct_round_trips
|
from storage.journal_backfill import flatten_fills, reconstruct_round_trips
|
||||||
|
from tradier_broker.factory import make_trading_client
|
||||||
|
|
||||||
store = EventStore("momentum")
|
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)
|
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))
|
trips = reconstruct_round_trips(flatten_fills(raw))
|
||||||
print(f"reconstructed {len(trips)} completed round-trips")
|
print(f"reconstructed {len(trips)} completed round-trips")
|
||||||
|
|
||||||
|
|||||||
@@ -119,15 +119,16 @@ def main() -> int:
|
|||||||
from runtime.env_overrides import load as _load_overrides
|
from runtime.env_overrides import load as _load_overrides
|
||||||
_load_overrides()
|
_load_overrides()
|
||||||
|
|
||||||
from alpaca_paper.client import AlpacaPaperClient
|
|
||||||
from alpaca_paper.settings import AlpacaPaperSettings
|
|
||||||
from storage.event_store import EventStore
|
from storage.event_store import EventStore
|
||||||
|
from tradier_broker.factory import make_trading_client
|
||||||
|
|
||||||
settings = AlpacaPaperSettings.from_env()
|
# reconcile against the ACTIVE execution broker (BROKER env), never a
|
||||||
if not settings.is_configured:
|
# hardwired one — drift-checking the wrong broker is silently meaningless
|
||||||
print("[eod_reconcile] no Alpaca keys — skipping", flush=True)
|
client, label = make_trading_client()
|
||||||
|
if client is None:
|
||||||
|
print(f"[eod_reconcile] {label} — skipping", flush=True)
|
||||||
return 0
|
return 0
|
||||||
client = AlpacaPaperClient(settings)
|
print(f"[eod_reconcile] reconciling against {label}", flush=True)
|
||||||
store = EventStore(os.environ.get("WATCHER_EVENT_DB_PATH", "momentum"))
|
store = EventStore(os.environ.get("WATCHER_EVENT_DB_PATH", "momentum"))
|
||||||
try:
|
try:
|
||||||
from runtime.notifier import send_telegram
|
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