From 1cefb51c6338d81a1fb0491fcd48dece736474c2 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Sun, 26 Jul 2026 11:01:50 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Tradier=20broker=20mirror=20=E2=80=94?= =?UTF-8?q?=20config-switch=20redundancy=20for=20Alpaca=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tradier_broker/ mirrors every trading call the live loop makes on AlpacaPaperClient (account, clock, positions, orders, submit incl. bracket->OTOCO, cancel, replace, close) with identical signatures and Alpaca-shaped returns, so trading_execution / exit_manager / flatten / execution / sync run unchanged. Market data always delegates to Alpaca. - BROKER=tradier (+ TRADIER_* env) swaps execution client in run_live_paper; default path untouched - client_order_id -> tag preserves the lost-submit reconcile - TradierApiError subclasses AlpacaApiError so the executor's rejection-vs-transport fork works as-is - sandbox-verified: balances/clock/positions/orders, OTOCO preview (result:true), place -> tag lookup -> cancel round trip - 18 unit tests (translation + client paths); executor integration checked - docs/TRADIER-FALLBACK.md switch runbook Co-Authored-By: Claude Fable 5 --- alpaca_paper/execution.py | 5 +- docs/TRADIER-FALLBACK.md | 53 ++++ run_live_paper.py | 16 ++ tests/test_tradier_client.py | 207 ++++++++++++++ tradier_broker/__init__.py | 18 ++ tradier_broker/client.py | 528 +++++++++++++++++++++++++++++++++++ tradier_broker/settings.py | 39 +++ tradier_broker/smoke.py | 95 +++++++ 8 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 docs/TRADIER-FALLBACK.md create mode 100644 tests/test_tradier_client.py create mode 100644 tradier_broker/__init__.py create mode 100644 tradier_broker/client.py create mode 100644 tradier_broker/settings.py create mode 100644 tradier_broker/smoke.py diff --git a/alpaca_paper/execution.py b/alpaca_paper/execution.py index ff7b40b..bf80291 100644 --- a/alpaca_paper/execution.py +++ b/alpaca_paper/execution.py @@ -93,6 +93,9 @@ class AlpacaPaperExecutor: ): self.store = store self.client = client or AlpacaPaperClient() + # honest broker label in journaled events when a mirror client + # (e.g. TradierClient) is injected + self.broker_name = getattr(self.client, "broker_name", "alpaca_paper") self.session_id = session_id self.mode = mode @@ -182,7 +185,7 @@ class AlpacaPaperExecutor: correlation_id=self.session_id, message=( f"Submitted {request.side} {request.quantity} {request.symbol} " - f"to alpaca_paper ({status})" + f"to {self.broker_name} ({status})" ), symbol=request.symbol, order_id=request.order_id, diff --git a/docs/TRADIER-FALLBACK.md b/docs/TRADIER-FALLBACK.md new file mode 100644 index 0000000..59de4fd --- /dev/null +++ b/docs/TRADIER-FALLBACK.md @@ -0,0 +1,53 @@ +# Tradier broker fallback + +Redundancy path if the Alpaca live account stays blocked. `tradier_broker/` +mirrors every trading call the live loop makes on `AlpacaPaperClient` — same +method names, signatures, and Alpaca-shaped return dicts — so +`trading_execution`, `runtime/exit_manager`, `runtime/flatten`, +`alpaca_paper.execution` and `alpaca_paper.sync` run unchanged. + +## Switch (config only) + +``` +BROKER=tradier +TRADIER_ACCESS_TOKEN=... # sandbox or live token +TRADIER_ACCOUNT_ID=... # e.g. VA71549758 +TRADIER_ENV=sandbox # 'live' for real money — explicit opt-in +``` + +`BROKER` unset/`alpaca` = today's behavior, byte-for-byte. Alpaca keys stay +required either way: **market data (bars, screeners, quotes, news) always +stays on Alpaca** — the Tradier client only replaces order execution and +account/position state, delegating all data calls to the Alpaca client. + +## Verify before trusting + +``` +python3 -m tradier_broker.smoke # balances/clock/positions/orders +python3 -m tradier_broker.smoke --preview AAPL # broker-validates bracket form, never submits +python3 -m tradier_broker.smoke --place-cancel AAPL # full submit -> tag lookup -> cancel +``` + +Smoke refuses order exercises against the live base URL. + +## Mapping notes + +- Alpaca bracket -> Tradier OTOCO (entry + OCO take-profit limit / stop); + oto -> OTO; standalone gtc stop sells map directly. +- `client_order_id` -> Tradier `tag` (lost-submit reconcile works via tag lookup). +- Tradier positions carry no mark; `get_positions` prices them off + `/markets/quotes` (sandbox quotes are 15-min delayed — fine for redundancy + checks, and live tokens get real-time). +- `replace_order` maps to PUT (moves stop/limit without cancelling — OCO stays + intact); Tradier cannot change qty on a working order (raises, cancel+resubmit). +- `get_orders` is session-scoped at Tradier (today's orders) and status/symbol + filters are client-side; the live loop only queries today + held symbols, so + behavior matches. + +## Before flipping live money to Tradier + +1. Rotate the sandbox token (it transited chat) and mint a live token. +2. Run a full paper week on sandbox with `BROKER=tradier` in the paper stack. +3. Re-run the naked-stop drill from docs/LIVE-CUTOVER.md — leg-stripping + semantics differ per broker; verify a filled OTOCO's stop leg survives an + entry-order cancel attempt. diff --git a/run_live_paper.py b/run_live_paper.py index e36e511..6657ffb 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -246,6 +246,22 @@ def build_runtime(args: argparse.Namespace) -> dict: has_keys = alpaca_settings.is_configured client = AlpacaPaperClient(alpaca_settings) if has_keys else None + # Broker failover: BROKER=tradier swaps the EXECUTION client for the + # Tradier mirror (same surface/shapes); market data stays on Alpaca via + # delegation. Default (unset/alpaca) is untouched. + if os.environ.get("BROKER", "alpaca").strip().lower() == "tradier": + from tradier_broker.client import TradierClient + from tradier_broker.settings import TradierSettings + tradier_settings = TradierSettings.from_env() + if tradier_settings.is_configured: + client = TradierClient(tradier_settings, data_client=client) + print(f"[boot] BROKER=tradier: executing via Tradier " + f"({'SANDBOX' if tradier_settings.is_sandbox else 'LIVE'}), " + f"data via Alpaca", flush=True) + else: + print("[boot] WARNING: BROKER=tradier but TRADIER_ACCESS_TOKEN/" + "TRADIER_ACCOUNT_ID missing — staying on Alpaca", flush=True) + # Datastore is Postgres (DATABASE_URL); WATCHER_EVENT_DB_PATH is a legacy # fallback the connection layer ignores. event_db = os.environ.get("WATCHER_EVENT_DB_PATH", "momentum") diff --git a/tests/test_tradier_client.py b/tests/test_tradier_client.py new file mode 100644 index 0000000..56a89bd --- /dev/null +++ b/tests/test_tradier_client.py @@ -0,0 +1,207 @@ +"""Tradier mirror translation tests — pure functions plus fake-network client +paths. No DB, no network.""" + +import unittest +from unittest.mock import patch + +from tradier_broker.client import ( + TradierClient, + build_order_form, + translate_balances, + translate_order, + translate_position, +) +from tradier_broker.settings import TradierSettings + + +SETTINGS = TradierSettings(access_token="t", account_id="VA000") + + +class TranslateOrderTests(unittest.TestCase): + def test_filled_equity_order_maps_alpaca_keys(self): + o = translate_order({ + "id": 123456, "type": "limit", "symbol": "AAPL", "side": "buy", + "quantity": 10, "status": "filled", "duration": "day", + "price": 175.0, "avg_fill_price": 128.25, "exec_quantity": 10, + "create_date": "2026-07-25T15:25:47.000Z", + "transaction_date": "2026-07-25T15:25:50.000Z", + "class": "equity", "tag": "abc-123", + }) + self.assertEqual(o["id"], "123456") + self.assertEqual(o["client_order_id"], "abc-123") + self.assertEqual(o["qty"], "10") + self.assertEqual(o["filled_qty"], "10") + self.assertEqual(o["filled_avg_price"], "128.25") + self.assertEqual(o["limit_price"], "175.0") + self.assertEqual(o["status"], "filled") + self.assertEqual(o["submitted_at"], "2026-07-25T15:25:47.000Z") + self.assertEqual(o["filled_at"], "2026-07-25T15:25:50.000Z") + + def test_status_mapping(self): + for tradier, alpaca in [("open", "new"), ("pending", "pending_new"), + ("error", "rejected"), ("submitted", "accepted"), + ("canceled", "canceled")]: + self.assertEqual(translate_order({"status": tradier})["status"], alpaca) + + def test_unfilled_has_no_filled_at(self): + o = translate_order({"status": "open", "transaction_date": "x"}) + self.assertIsNone(o["filled_at"]) + + def test_otoco_legs_become_alpaca_legs(self): + o = translate_order({ + "id": 1, "class": "otoco", "status": "filled", "symbol": "PN", + "leg": [ + {"id": 2, "type": "limit", "side": "sell", "status": "open", + "symbol": "PN", "quantity": 5, "price": 9.0}, + {"id": 3, "type": "stop", "side": "sell", "status": "open", + "symbol": "PN", "quantity": 5, "stop_price": 7.0}, + ], + }) + self.assertEqual(o["order_class"], "bracket") + self.assertEqual(len(o["legs"]), 2) + self.assertEqual(o["legs"][1]["stop_price"], "7.0") + self.assertEqual(o["legs"][0]["limit_price"], "9.0") + self.assertEqual(o["legs"][0]["status"], "new") + + def test_single_leg_dict_normalized(self): + o = translate_order({"class": "oto", "status": "open", + "leg": {"id": 9, "type": "stop", "stop": 4.5, + "status": "open"}}) + self.assertEqual(len(o["legs"]), 1) + self.assertEqual(o["legs"][0]["stop_price"], "4.5") + + +class TranslatePositionTests(unittest.TestCase): + def test_marked_position(self): + p = translate_position( + {"symbol": "ADVB", "quantity": 100, "cost_basis": 500.0}, 6.25) + self.assertEqual(p["qty"], "100") + self.assertEqual(p["avg_entry_price"], "5.0") + self.assertEqual(p["market_value"], "625.0") + self.assertEqual(p["unrealized_pl"], "125.0") + self.assertEqual(p["side"], "long") + + def test_unmarked_position_still_reports(self): + p = translate_position({"symbol": "X", "quantity": 10, "cost_basis": 50}, None) + self.assertIsNone(p["current_price"]) + self.assertIsNone(p["unrealized_pl"]) + self.assertEqual(p["avg_entry_price"], "5.0") + + +class TranslateBalancesTests(unittest.TestCase): + def test_margin_account(self): + a = translate_balances({"total_equity": 100000.5, "total_cash": 40000, + "margin": {"stock_buying_power": 80000}}) + self.assertEqual(a["equity"], "100000.5") + self.assertEqual(a["buying_power"], "80000") + + def test_cash_account_fallback(self): + a = translate_balances({"total_equity": 100, "total_cash": 100, + "cash": {"cash_available": 90}}) + self.assertEqual(a["buying_power"], "90") + + +class BuildOrderFormTests(unittest.TestCase): + def test_bracket_becomes_otoco(self): + f = build_order_form("PN", 50, "buy", "limit", "day", + limit_price=8.5, stop_price=None, + stop_loss_price=7.9, take_profit_price=10.2, + client_order_id="cid-1") + self.assertEqual(f["class"], "otoco") + self.assertEqual(f["type[0]"], "limit") + self.assertEqual(f["price[0]"], "8.5") + self.assertEqual(f["side[1]"], "sell") + self.assertEqual(f["price[1]"], "10.2") + self.assertEqual(f["type[2]"], "stop") + self.assertEqual(f["stop[2]"], "7.9") + self.assertEqual(f["tag"], "cid-1") + # OCO legs must share symbol and differ in type (Tradier hard rule) + self.assertEqual(f["symbol[1]"], f["symbol[2]"]) + self.assertNotEqual(f["type[1]"], f["type[2]"]) + + def test_stop_only_becomes_oto(self): + f = build_order_form("PN", 50, "buy", "limit", "day", + limit_price=8.5, stop_price=None, + stop_loss_price=7.9, take_profit_price=None, + client_order_id=None) + self.assertEqual(f["class"], "oto") + self.assertEqual(f["type[1]"], "stop") + self.assertEqual(f["stop[1]"], "7.9") + + def test_plain_stop_sell_gtc(self): + # exit_manager's standalone protective stop + f = build_order_form("PN", 50, "sell", "stop", "gtc", + limit_price=None, stop_price=7.5, + stop_loss_price=None, take_profit_price=None, + client_order_id=None) + self.assertEqual(f["class"], "equity") + self.assertEqual(f["type"], "stop") + self.assertEqual(f["stop"], "7.5") + self.assertEqual(f["duration"], "gtc") + + def test_plain_market(self): + f = build_order_form("PN", 50, "sell", "market", "day", + None, None, None, None, None) + self.assertEqual(f["class"], "equity") + self.assertNotIn("price", f) + + +class ClientPathTests(unittest.TestCase): + """Fake _request — verify filtering/normalization the live loop relies on.""" + + def _client_with_orders(self, orders_node): + client = TradierClient(SETTINGS) + patcher = patch.object( + client, "_request", + side_effect=lambda method, path, **kw: ( + {"orders": orders_node} + if kw.get("params", {}).get("page", 0) == 1 or orders_node in (None, "null") + else {"orders": "null"}), + ) + patcher.start() + self.addCleanup(patcher.stop) + return client + + def test_orders_null_quirk(self): + client = self._client_with_orders("null") + self.assertEqual(client.get_orders(), []) + + def test_symbols_and_status_filter(self): + client = self._client_with_orders({"order": [ + {"id": 1, "symbol": "PN", "status": "open"}, + {"id": 2, "symbol": "PN", "status": "filled"}, + {"id": 3, "symbol": "ADVB", "status": "open"}, + ]}) + open_pn = client.get_orders(status="open", symbols=["PN"]) + self.assertEqual([o["id"] for o in open_pn], ["1"]) + + def test_lookup_by_tag(self): + client = self._client_with_orders({"order": [ + {"id": 1, "symbol": "PN", "status": "open", "tag": "want"}, + {"id": 2, "symbol": "PN", "status": "open", "tag": "other"}, + ]}) + found = client.get_order_by_client_order_id("want") + self.assertIsNotNone(found) + self.assertEqual(found["id"], "1") + self.assertIsNone(client.get_order_by_client_order_id("missing")) + + def test_submit_translates_response(self): + client = TradierClient(SETTINGS) + with patch.object(client, "_request", + return_value={"order": {"id": 77, "status": "ok"}}): + resp = client.submit_order(symbol="PN", qty=5, side="buy", + order_type="limit", limit_price=8.5, + stop_loss_price=7.9, take_profit_price=10.0, + client_order_id="cid") + self.assertEqual(resp["id"], "77") + self.assertEqual(resp["status"], "accepted") + self.assertEqual(resp["filled_qty"], "0") + + def test_replace_qty_unsupported(self): + client = TradierClient(SETTINGS) + with self.assertRaises(ValueError): + client.replace_order("1", qty=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradier_broker/__init__.py b/tradier_broker/__init__.py new file mode 100644 index 0000000..9ef4e0e --- /dev/null +++ b/tradier_broker/__init__.py @@ -0,0 +1,18 @@ +"""Tradier brokerage mirror of the Alpaca execution surface. + +Redundancy path: if the Alpaca live account stays blocked, the live loop can +switch brokers with BROKER=tradier (+ TRADIER_* env). The client exposes the +same method names/signatures as AlpacaPaperClient for every trading call the +live loop uses, and returns Alpaca-SHAPED dicts, so trading_execution, +runtime/exit_manager, runtime/flatten, alpaca_paper.sync and +alpaca_paper.execution all run unchanged. + +Market data (bars, screeners, quotes, news) stays on Alpaca — data keys work +without a live brokerage account — via an injected AlpacaPaperClient the +Tradier client delegates data calls to. +""" + +from tradier_broker.client import TradierApiError, TradierClient +from tradier_broker.settings import TradierSettings + +__all__ = ["TradierClient", "TradierApiError", "TradierSettings"] diff --git a/tradier_broker/client.py b/tradier_broker/client.py new file mode 100644 index 0000000..e422f91 --- /dev/null +++ b/tradier_broker/client.py @@ -0,0 +1,528 @@ +"""Minimal Tradier REST client mirroring AlpacaPaperClient's trading surface. + +Stdlib only. Every trading method the live loop touches — account, clock, +positions, orders (list / by-client-id), submit (incl. bracket via OTOCO), +cancel, replace, close — has the SAME name, signature and Alpaca-shaped +return dict as AlpacaPaperClient, so all downstream consumers run unchanged. + +Key mappings (Tradier -> Alpaca shape): + order: exec_quantity->filled_qty, avg_fill_price->filled_avg_price, + tag->client_order_id, create_date->submitted_at, leg->legs, + price->limit_price, stop_price/stop->stop_price + status: open->new, pending->pending_new, submitted->accepted, error->rejected + position: cost_basis/quantity->avg_entry_price; current_price/market_value/ + unrealized_pl derived from /markets/quotes (Tradier positions + carry no mark) + account: balances.total_equity->equity, total_cash->cash, margin/cash/pdt + buying power -> buying_power + bracket: Alpaca order_class=bracket -> Tradier class=otoco (entry + OCO + take-profit limit / stop); oto -> class=oto + +Market data calls delegate to an injected AlpacaPaperClient (Alpaca data keys +work without a live brokerage account; Tradier sandbox quotes are 15-min +delayed, so they are only used to mark positions). +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +# importing the alpaca client also installs its process-wide DNS cache, which +# covers the Tradier hostnames too (same flaky-DNS host) +from alpaca_paper.client import AlpacaApiError, AlpacaPaperClient +from tradier_broker.settings import TradierSettings + +logger = logging.getLogger(__name__) + + +class TradierApiError(AlpacaApiError): + """Subclass so AlpacaPaperExecutor's `except AlpacaApiError` (genuine broker + rejection vs transport error — the lost-submit reconcile fork) works + unchanged with this client injected.""" + + def __init__(self, status: int, body: str): + super().__init__(status, body) + # more honest message than the parent's "Alpaca API error" + self.args = (f"Tradier API error {status}: {body[:300]}",) + + +# -- pure translation helpers (unit-testable, no network) ----------------- + +_STATUS_MAP = { + "open": "new", + "pending": "pending_new", + "submitted": "accepted", + "partially_filled": "partially_filled", + "filled": "filled", + "canceled": "canceled", + "rejected": "rejected", + "expired": "expired", + "error": "rejected", + "held": "held", + "calculated": "accepted", + "ok": "accepted", # POST /orders success body carries status=ok +} + + +def _s(v) -> str | None: + """Tradier returns numbers; Alpaca returns strings. Stringify, None-safe.""" + return None if v is None else str(v) + + +def _one_or_many(node, key: str) -> list[dict]: + """Tradier wraps lists oddly: {"orders": "null"} when empty, a bare dict for + a single item, a list otherwise. Normalize to a list of dicts.""" + if not node or node == "null": + return [] + inner = node.get(key) if isinstance(node, dict) else None + if inner is None or inner == "null": + return [] + return inner if isinstance(inner, list) else [inner] + + +def translate_order(o: dict) -> dict: + status = _STATUS_MAP.get(str(o.get("status", "")).lower(), + str(o.get("status", "")).lower()) + legs = [translate_order(leg) for leg in _one_or_many(o, "leg")] \ + if isinstance(o.get("leg"), (list, dict)) else None + filled_at = o.get("transaction_date") if status == "filled" else None + return { + "id": _s(o.get("id")), + "client_order_id": o.get("tag") or "", + "symbol": o.get("symbol"), + "qty": _s(o.get("quantity") or 0), + "filled_qty": _s(o.get("exec_quantity") or 0), + "filled_avg_price": _s(o.get("avg_fill_price")), + "side": o.get("side"), + "type": o.get("type"), + "order_type": o.get("type"), + "status": status, + "limit_price": _s(o.get("price")), + "stop_price": _s(o.get("stop_price", o.get("stop"))), + "time_in_force": o.get("duration"), + "submitted_at": o.get("create_date"), + "created_at": o.get("create_date"), + "updated_at": o.get("transaction_date"), + "filled_at": filled_at, + "order_class": "bracket" if o.get("class") == "otoco" else ( + "oto" if o.get("class") == "oto" else ""), + "legs": legs, + "_tradier": {"class": o.get("class"), "status": o.get("status")}, + } + + +def translate_position(p: dict, last_price: float | None) -> dict: + qty = float(p.get("quantity") or 0) + cost = float(p.get("cost_basis") or 0) + avg = cost / qty if qty else 0.0 + mv = (last_price * qty) if last_price is not None else None + upl = (mv - cost) if mv is not None else None + return { + "symbol": p.get("symbol"), + "qty": str(int(qty)) if float(qty).is_integer() else str(qty), + "side": "long" if qty >= 0 else "short", + "avg_entry_price": str(round(avg, 4)), + "cost_basis": str(cost), + "current_price": _s(last_price), + "market_value": _s(round(mv, 2) if mv is not None else None), + "unrealized_pl": _s(round(upl, 2) if upl is not None else None), + "unrealized_plpc": _s(round(upl / cost, 6) if upl is not None and cost else None), + } + + +def translate_balances(b: dict) -> dict: + margin = b.get("margin") or {} + cash_bp = b.get("cash") or {} + pdt = b.get("pdt") or {} + buying_power = ( + margin.get("stock_buying_power") + or pdt.get("stock_buying_power") + or cash_bp.get("cash_available") + or b.get("total_cash") + or 0 + ) + return { + "equity": _s(b.get("total_equity") or 0), + "last_equity": _s(b.get("total_equity") or 0), + "cash": _s(b.get("total_cash") or 0), + "buying_power": _s(buying_power), + "status": "ACTIVE", + "account_number": _s(b.get("account_number")), + "currency": "USD", + } + + +def build_order_form( + symbol: str, + qty: int, + side: str, + order_type: str, + time_in_force: str, + limit_price: float | None, + stop_price: float | None, + stop_loss_price: float | None, + take_profit_price: float | None, + client_order_id: str | None, +) -> dict: + """Alpaca submit_order args -> Tradier POST /orders form fields.""" + duration = "gtc" if time_in_force == "gtc" else "day" + exit_side = "sell" if side == "buy" else "buy_to_cover" + + if stop_loss_price is not None and take_profit_price is not None: + # Alpaca bracket == Tradier OTOCO: entry triggers an OCO pair + form = { + "class": "otoco", "duration": duration, + "symbol[0]": symbol, "side[0]": side, "quantity[0]": str(qty), + "type[0]": order_type, + "symbol[1]": symbol, "side[1]": exit_side, "quantity[1]": str(qty), + "type[1]": "limit", "price[1]": str(round(take_profit_price, 2)), + "symbol[2]": symbol, "side[2]": exit_side, "quantity[2]": str(qty), + "type[2]": "stop", "stop[2]": str(round(stop_loss_price, 2)), + } + if limit_price is not None: + form["price[0]"] = str(round(limit_price, 2)) + if stop_price is not None: + form["stop[0]"] = str(round(stop_price, 2)) + elif stop_loss_price is not None or take_profit_price is not None: + # Alpaca oto == Tradier OTO: entry triggers one exit + form = { + "class": "oto", "duration": duration, + "symbol[0]": symbol, "side[0]": side, "quantity[0]": str(qty), + "type[0]": order_type, + "symbol[1]": symbol, "side[1]": exit_side, "quantity[1]": str(qty), + } + if limit_price is not None: + form["price[0]"] = str(round(limit_price, 2)) + if stop_price is not None: + form["stop[0]"] = str(round(stop_price, 2)) + if take_profit_price is not None: + form["type[1]"] = "limit" + form["price[1]"] = str(round(take_profit_price, 2)) + else: + form["type[1]"] = "stop" + form["stop[1]"] = str(round(stop_loss_price, 2)) # type: ignore[arg-type] + else: + form = { + "class": "equity", "symbol": symbol, "side": side, + "quantity": str(qty), "type": order_type, "duration": duration, + } + if limit_price is not None: + form["price"] = str(round(limit_price, 2)) + if stop_price is not None: + form["stop"] = str(round(stop_price, 2)) + if client_order_id: + # Tradier tag: letters/numbers/dashes, <=255 chars — our uuid4 ids fit + form["tag"] = client_order_id[:255] + return form + + +# -- client ---------------------------------------------------------------- + +_OPEN_STATUSES = {"new", "pending_new", "accepted", "partially_filled", "held"} + + +class TradierClient: + broker_name = "tradier" + + def __init__( + self, + settings: TradierSettings | None = None, + data_client: AlpacaPaperClient | None = None, + timeout: int = 15, + max_retries: int = 2, + ): + self.settings = settings or TradierSettings.from_env() + self.data_client = data_client + self.timeout = timeout + self.max_retries = max_retries + # same tiny TTL cache as the Alpaca client: collapses the redundant + # account/positions reads the breaker/sync/guard make every pass + self._cache: dict = {} + self._cache_lock = threading.Lock() + self.cache_ttl = 2.5 + + def _cached(self, key: str, fn): + now = time.monotonic() + with self._cache_lock: + hit = self._cache.get(key) + if hit and (now - hit[1]) < self.cache_ttl: + return hit[0] + val = fn() + with self._cache_lock: + self._cache[key] = (val, time.monotonic()) + return val + + def _invalidate_cache(self): + with self._cache_lock: + self._cache.clear() + + # -- low level --------------------------------------------------------- + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.settings.access_token}", + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + } + + def _request(self, method: str, path: str, params: dict | None = None, + form: dict | None = None): + url = f"{self.settings.base_url}{path}" + if params: + qs = urllib.parse.urlencode( + {k: v for k, v in params.items() if v is not None}) + url = f"{url}?{qs}" + data = urllib.parse.urlencode(form).encode() if form is not None else None + req = urllib.request.Request(url, data=data, headers=self._headers(), + method=method) + for attempt in range(self.max_retries + 1): + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read().decode() + payload = json.loads(raw) if raw else {} + # Tradier can return 200 with an errors envelope + if isinstance(payload, dict) and payload.get("errors"): + raise TradierApiError(resp.status, raw) + return payload + except urllib.error.HTTPError as exc: + # 429/5xx transient — retry with backoff (mirrors Alpaca client) + if exc.code in (429, 500, 502, 503, 504) and attempt < self.max_retries: + retry_after = exc.headers.get("Retry-After") if exc.headers else None + try: + delay = float(retry_after) if retry_after else 0.0 + except (TypeError, ValueError): + delay = 0.0 + time.sleep(max(delay, 0.5 * (2 ** attempt))) + continue + raise TradierApiError(exc.code, exc.read().decode(errors="replace")) from exc + except urllib.error.URLError: + # transient network/DNS. NOT for POST/PUT: the request may have + # LANDED with the response lost — re-sending rests a duplicate + # order. Caller reconciles via get_order_by_client_order_id + # (same contract as the Alpaca client). + if method in ("POST", "PUT") or attempt >= self.max_retries: + raise + time.sleep(0.4 * (attempt + 1)) + raise RuntimeError("request retries exhausted") # unreachable + + def _acct(self, method: str, path: str = "", **kw): + return self._request( + method, f"/accounts/{self.settings.account_id}{path}", **kw) + + # -- trading API (Alpaca-shaped) ---------------------------------------- + + def get_account(self) -> dict: + def fetch(): + payload = self._acct("GET", "/balances") or {} + return translate_balances(payload.get("balances") or {}) + return self._cached("account", fetch) + + def get_clock(self) -> dict: + payload = self._request("GET", "/markets/clock") or {} + clock = payload.get("clock") or {} + return { + "is_open": str(clock.get("state", "")).lower() == "open", + "timestamp": clock.get("timestamp"), + "next_change": clock.get("next_change"), + "state": clock.get("state"), + "description": clock.get("description"), + } + + def _positions_fresh(self) -> list[dict]: + payload = self._acct("GET", "/positions") or {} + raw = _one_or_many(payload.get("positions"), "position") + if not raw: + return [] + # Tradier positions carry no mark — price them off /markets/quotes + symbols = sorted({str(p["symbol"]) for p in raw if p.get("symbol")}) + lasts: dict[str, float] = {} + try: + qp = self._request("GET", "/markets/quotes", + params={"symbols": ",".join(symbols)}) or {} + for q in _one_or_many(qp.get("quotes"), "quote"): + last = q.get("last") + if q.get("symbol") and last is not None: + lasts[q["symbol"]] = float(last) + except Exception: # noqa: BLE001 — quotes down: positions still report, unmarked + logger.warning("tradier quotes lookup failed; positions unmarked") + return [translate_position(p, lasts.get(str(p.get("symbol") or ""))) for p in raw] + + def get_positions(self, fresh: bool = False) -> list[dict]: + if fresh: + val = self._positions_fresh() + with self._cache_lock: + self._cache["positions"] = (val, time.monotonic()) + return val + return self._cached("positions", self._positions_fresh) + + def _orders_raw(self) -> list[dict]: + """All orders for the current session, paged, tags included.""" + out: list[dict] = [] + seen: set = set() + for page in range(1, 21): + payload = self._acct("GET", "/orders", + params={"includeTags": "true", "page": page}) or {} + batch = _one_or_many(payload.get("orders"), "order") + fresh = [o for o in batch if o.get("id") not in seen] + for o in fresh: + seen.add(o.get("id")) + out.extend(fresh) + if not batch or not fresh: + break + return out + + def get_orders( + self, status: str = "all", limit: int = 500, nested: bool = True, + symbols: list[str] | None = None, after: str | None = None, + ) -> list[dict]: + # Tradier has no server-side status/symbol filters — one paged fetch, + # filter here. Legs always come inline (the `nested` arg is accepted + # for signature parity and ignored). + orders = [translate_order(o) for o in self._orders_raw()] + if symbols: + want = {s.upper() for s in symbols} + orders = [o for o in orders if (o.get("symbol") or "").upper() in want] + if status == "open": + orders = [o for o in orders if o.get("status") in _OPEN_STATUSES] + elif status == "closed": + orders = [o for o in orders if o.get("status") not in _OPEN_STATUSES] + if after: + orders = [o for o in orders + if (o.get("submitted_at") or "") > after] + return orders[:limit] + + def get_order_by_client_order_id(self, client_order_id: str) -> dict | None: + for o in self._orders_raw(): + if (o.get("tag") or "") == client_order_id: + return translate_order(o) + return None + + def submit_order( + self, + symbol: str, + qty: int, + side: str = "buy", + order_type: str = "market", + time_in_force: str = "day", + limit_price: float | None = None, + stop_price: float | None = None, + stop_loss_price: float | None = None, + take_profit_price: float | None = None, + client_order_id: str | None = None, + ) -> dict: + self._invalidate_cache() + form = build_order_form(symbol, qty, side, order_type, time_in_force, + limit_price, stop_price, stop_loss_price, + take_profit_price, client_order_id) + payload = self._acct("POST", "/orders", form=form) or {} + order = payload.get("order") or {} + broker_id = order.get("id") + # POST returns only {id, status:"ok"} — echo request fields so the + # executor's event payloads are complete without a second GET + return { + "id": _s(broker_id), + "client_order_id": client_order_id or "", + "symbol": symbol, + "qty": str(qty), + "filled_qty": "0", + "filled_avg_price": None, + "side": side, + "type": order_type, + "order_type": order_type, + "status": _STATUS_MAP.get(str(order.get("status", "")).lower(), + "accepted" if broker_id else "rejected"), + "limit_price": _s(limit_price), + "stop_price": _s(stop_price), + "time_in_force": time_in_force, + "legs": None, + "_tradier": order, + } + + def cancel_order(self, order_id: str) -> None: + self._invalidate_cache() + self._acct("DELETE", f"/orders/{order_id}") + + def replace_order( + self, order_id: str, stop_price: float | None = None, + limit_price: float | None = None, qty: int | None = None, + ) -> dict: + """PUT /orders/{id} — moves a working order's stop/limit price without + cancelling it (keeps OCO protection intact, same contract as Alpaca's + PATCH). Tradier cannot change quantity on a working order.""" + if qty is not None: + raise ValueError("Tradier cannot change qty on a working order; " + "cancel and resubmit instead") + self._invalidate_cache() + form: dict = {} + if stop_price is not None: + form["stop"] = str(round(stop_price, 2)) + if limit_price is not None: + form["price"] = str(round(limit_price, 2)) + if not form: + return {} + payload = self._acct("PUT", f"/orders/{order_id}", form=form) or {} + order = payload.get("order") or {} + return {"id": _s(order.get("id") or order_id), + "status": _STATUS_MAP.get(str(order.get("status", "")).lower(), + "accepted")} + + def close_position(self, symbol: str, qty: int | None = None, + percentage: float | None = None) -> dict: + """Market-sell held shares (Tradier has no close-position endpoint).""" + self._invalidate_cache() + held = 0 + for p in self.get_positions(fresh=True): + if (p.get("symbol") or "").upper() == symbol.upper(): + held = int(float(p.get("qty") or 0)) + break + if held <= 0: + raise TradierApiError(404, f"no long position in {symbol}") + sell = held + if qty is not None: + sell = min(int(qty), held) + elif percentage is not None: + sell = max(1, int(held * percentage / 100.0)) + return self.submit_order(symbol=symbol, qty=sell, side="sell", + order_type="market") + + # -- market data: delegate to Alpaca ------------------------------------ + # Data does not require a live brokerage account, and every research / + # ingest / discovery path is calibrated to Alpaca feeds — broker failover + # must not change the data plane. + + def _data(self): + if self.data_client is None: + raise RuntimeError( + "TradierClient has no Alpaca data_client; market data stays on " + "Alpaca — construct TradierClient(data_client=AlpacaPaperClient())") + return self.data_client + + def get_minute_bars(self, *a, **kw): + return self._data().get_minute_bars(*a, **kw) + + def get_latest_trades(self, *a, **kw): + return self._data().get_latest_trades(*a, **kw) + + def get_latest_quotes(self, *a, **kw): + return self._data().get_latest_quotes(*a, **kw) + + def get_most_actives(self, *a, **kw): + return self._data().get_most_actives(*a, **kw) + + def get_movers(self, *a, **kw): + return self._data().get_movers(*a, **kw) + + def get_news(self, *a, **kw): + return self._data().get_news(*a, **kw) + + def get_daily_bars(self, *a, **kw): + return self._data().get_daily_bars(*a, **kw) + + def get_assets(self, *a, **kw): + return self._data().get_assets(*a, **kw) diff --git a/tradier_broker/settings.py b/tradier_broker/settings.py new file mode 100644 index 0000000..7c0c131 --- /dev/null +++ b/tradier_broker/settings.py @@ -0,0 +1,39 @@ +"""Tradier brokerage settings (sandbox by default — live is an explicit opt-in).""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +SANDBOX_BASE_URL = "https://sandbox.tradier.com/v1" +LIVE_BASE_URL = "https://api.tradier.com/v1" + + +@dataclass(frozen=True) +class TradierSettings: + access_token: str = "" + account_id: str = "" + base_url: str = SANDBOX_BASE_URL + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> "TradierSettings": + values = dict(os.environ) + if env is not None: + values.update(env) + env_name = values.get("TRADIER_ENV", "sandbox").strip().lower() + base = values.get("TRADIER_BASE_URL") or ( + LIVE_BASE_URL if env_name == "live" else SANDBOX_BASE_URL + ) + return cls( + access_token=values.get("TRADIER_ACCESS_TOKEN", "").strip(), + account_id=values.get("TRADIER_ACCOUNT_ID", "").strip(), + base_url=base.rstrip("/"), + ) + + @property + def is_configured(self) -> bool: + return bool(self.access_token and self.account_id) + + @property + def is_sandbox(self) -> bool: + return "sandbox" in self.base_url diff --git a/tradier_broker/smoke.py b/tradier_broker/smoke.py new file mode 100644 index 0000000..ef514c4 --- /dev/null +++ b/tradier_broker/smoke.py @@ -0,0 +1,95 @@ +"""Tradier sandbox smoke test — proves the mirror talks to the broker. + +Read-only by default: balances, clock, positions, orders. Optional order +exercises (sandbox-safe): + + python -m tradier_broker.smoke # read-only checks + python -m tradier_broker.smoke --preview AAPL # preview (never submits) a + # bracket + a market order + python -m tradier_broker.smoke --place-cancel AAPL # place a far-below-market + # limit buy, then cancel it + +Requires TRADIER_ACCESS_TOKEN + TRADIER_ACCOUNT_ID in env/.env. +Refuses order exercises against the live base URL. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time + +from dotenv import load_dotenv + +from tradier_broker.client import TradierClient, build_order_form +from tradier_broker.settings import TradierSettings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preview", metavar="SYMBOL", + help="preview (validate, never submit) a bracket and a market order") + parser.add_argument("--place-cancel", metavar="SYMBOL", + help="place a $0.50-limit buy for 1 share, then cancel it") + args = parser.parse_args(argv) + + load_dotenv() + settings = TradierSettings.from_env() + if not settings.is_configured: + print("TRADIER_ACCESS_TOKEN / TRADIER_ACCOUNT_ID not set") + return 1 + print(f"env: {'SANDBOX' if settings.is_sandbox else 'LIVE'} " + f"account: {settings.account_id}") + client = TradierClient(settings) + + account = client.get_account() + print(f"account: equity={account['equity']} cash={account['cash']} " + f"buying_power={account['buying_power']}") + clock = client.get_clock() + print(f"clock: state={clock['state']} is_open={clock['is_open']}") + positions = client.get_positions(fresh=True) + print(f"positions: {len(positions)}") + for p in positions: + print(f" {p['symbol']} qty={p['qty']} avg={p['avg_entry_price']} " + f"last={p['current_price']} upl={p['unrealized_pl']}") + orders = client.get_orders(status="all") + print(f"orders (session): {len(orders)}") + for o in orders[:10]: + print(f" {o['id']} {o['symbol']} {o['side']} {o['qty']} {o['type']} " + f"status={o['status']} tag={o['client_order_id'] or '-'}") + + if (args.preview or args.place_cancel) and not settings.is_sandbox: + print("refusing order exercises against LIVE base URL") + return 1 + + if args.preview: + sym = args.preview.upper() + for label, kw in ( + ("bracket limit", dict(order_type="limit", limit_price=1.00, + stop_loss_price=0.80, take_profit_price=1.50)), + ("plain market", dict(order_type="market")), + ): + form = build_order_form(sym, 1, "buy", kw.pop("order_type"), "day", + kw.get("limit_price"), None, + kw.get("stop_loss_price"), + kw.get("take_profit_price"), None) + form["preview"] = "true" + payload = client._acct("POST", "/orders", form=form) # noqa: SLF001 + print(f"preview {label}: {json.dumps(payload)[:400]}") + + if args.place_cancel: + sym = args.place_cancel.upper() + resp = client.submit_order(symbol=sym, qty=1, side="buy", + order_type="limit", limit_price=0.50, + client_order_id=f"smoke-{int(time.time())}") + print(f"placed: id={resp['id']} status={resp['status']}") + found = client.get_order_by_client_order_id(resp["client_order_id"]) + print(f"lookup by tag: {'FOUND ' + str(found['status']) if found else 'NOT FOUND'}") + client.cancel_order(resp["id"]) + print("cancelled ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main())