feat: Tradier broker mirror — config-switch redundancy for Alpaca execution

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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-26 11:01:50 -05:00
1 parent 48d75816b6
commit 1cefb51c63
8 files changed
+960 -1

No files matched your search

+4 -1
View File
@@ -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,
+53
View File
@@ -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.
+16
View File
@@ -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")
+207
View File
@@ -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()
+18
View File
@@ -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"]
+528
View File
@@ -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)
+39
View File
@@ -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
+95
View File
@@ -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())