Skip to content

feat: add Tastytrade broker (equities, options, multileg spreads) - #1010

Open
3452sdfgsdf wants to merge 9 commits into
Lumiwealth:devfrom
3452sdfgsdf:feature/tastytrade-broker
Open

feat: add Tastytrade broker (equities, options, multileg spreads)#1010
3452sdfgsdf wants to merge 9 commits into
Lumiwealth:devfrom
3452sdfgsdf:feature/tastytrade-broker

Conversation

@3452sdfgsdf

@3452sdfgsdf 3452sdfgsdf commented May 5, 2026

Copy link
Copy Markdown

Summary

Adds a Tastytrade broker built on the unofficial tastytrade Python SDK (12.4+). The SDK is async-only, so this adapter owns a daemon-thread asyncio bridge and dispatches every SDK call through asyncio.run_coroutine_threadsafe. Tastytrade was the only one of Lumibot's commonly-requested broker integrations that didn't ship; this fills that gap.

What works (live-validated against a production Tastytrade IRA)

  • OAuth auth (provider_secret + refresh_token, sandbox + prod)
  • Account.get + Account.get_balances
  • Account.get_positions (equity + equity options with OCC parsing)
  • market_data.get_market_data (last price, quote)
  • instruments.get_option_chain returned in Lumibot's nested {Multiplier, Chains: {CALL, PUT}} shape
  • Order place / replace / cancel:
    • Equity (BUY/SELL → BUY_TO_OPEN/SELL_TO_CLOSE per Tastytrade convention)
    • Single-leg equity options
    • Multileg equity-option spreads (credit/debit/even/limit)
  • Order parsing (PlacedOrder → Lumibot Order, with multileg parent+child expansion)
  • Order read-back (account.get_order, account.get_live_orders)
  • Polling stream dispatching NEW/FILLED/CANCELED/ERROR events to the strategy executor

Stubbed (follow-ups)

  • Advanced orders (OCO/OTO/bracket → NewComplexOrder)
  • Multileg _modify_order — current implementation only rebuilds a single leg, so spread modification is broken (cancel + resubmit works)
  • Native websocket streaming (AlertStreamer + DXLinkStreamer); polling is used today
  • Historical bars on TastytradeData

Wire-format gotchas discovered during live validation

  1. NewOrder.price sign encodes credit/debit (negative = debit, positive = credit). The SDK serializer strips abs() and emits price-effect on the wire. Sending a positive price for a BUY produces cant_buy_for_credit.
  2. Equity legs reject plain Buy/Sell from the OrderAction enum even though those values exist — the standard order endpoint requires the explicit open/close form for stocks (BUY_TO_OPEN, SELL_TO_CLOSE, etc.). Plain Buy returns order_legs.action: is invalid.
  3. replace_order returns a new order id (cancel-and-new pattern), not an in-place edit. _modify_order absorbs the new id back onto the local Order.
  4. Market data REST is aggressively rate-limited — two back-to-back get_market_data calls trip a 429. Active polling should debounce or wait for DXLinkStreamer integration.

Tests

20 offline unit tests covering:

  • OCC symbol formatting (round-trip)
  • Side / order-type / TIF enum mapping (equity vs option)
  • Equity + option submission (verifies Leg, NewOrder, signed price)
  • Multileg credit-spread submission (sign, mixed-underlying rejection)
  • PlacedOrder parsing (single leg + multileg parent/child)
  • Polling-stream wiring + _modify_order calls replace_order
  • Average-fill computation across leg fills
  • TastytradeData.get_last_price / get_quote / get_chains

Plus a live smoke test (test_live_sandbox_balances_and_positions) gated on TASTYTRADE_* env credentials, which exercises balances + positions against a real account. Maintainers with sandbox creds will see it run automatically.

Configuration

The broker reads credentials in this order: explicit kwargs > config dict > environment.

Env var Purpose
TASTYTRADE_CLIENT_SECRET OAuth provider secret
TASTYTRADE_REFRESH_TOKEN OAuth refresh token
TASTYTRADE_ACCOUNT_NUMBER Account number to trade
TASTYTRADE_SANDBOX true/1/yes to use Tastytrade's certification (sandbox) environment

Test plan

  • Equity place / replace / cancel against production IRA (visually confirmed)
  • Single-leg option place / replace / cancel against production IRA (visually confirmed)
  • Multileg credit put spread place / cancel against production IRA (visually confirmed)
  • Position parsing for equity + equity options
  • Live last_price + option chain fetch
  • Sandbox CI run (requires maintainer-provided sandbox creds in CI secrets)

Summary by CodeRabbit

  • New Features

    • Tastytrade broker integration and market-data source added, supporting single‑leg and multileg orders, positions (equities & OCC options), live polling/stream events, and quote/chain retrieval.
  • Limitations

    • Advanced OCO/OTO/bracket orders not supported; multileg order replacement not supported.
  • Tests

    • Comprehensive unit, smoke, and (opt-in) live sandbox tests for broker and data source.
  • Chores

    • Added pytest marker and test gating for Tastytrade.

Tod Kemper added 6 commits May 4, 2026 20:53
Adds an initial Tastytrade broker built on the unofficial
`tastytrade` Python SDK (12.4+). The SDK is async-only, so the
adapter owns a daemon-thread asyncio bridge and dispatches all
SDK calls via run_coroutine_threadsafe.

Working in this commit:
- OAuth auth (provider_secret + refresh_token, sandbox/prod)
- Account, balance, and position queries
- Order cancellation

Stubbed (logged warnings, follow-ups):
- Order submission, modification, and parsing
- Multileg orders
- Streaming (polling/AlertStreamer)
- Market data (chains/quotes/historical via DXLink)

Includes offline smoke tests (3 passing) plus a live sandbox
smoke test gated on TASTYTRADE_* env credentials.
Replaces the order-related stubs from the initial scaffold with real
implementations against the tastytrade SDK.

Order submission:
- _submit_order: equity + single-leg equity options (limit/market/stop/
  stop_limit). Builds a tastytrade.order.Leg and NewOrder and dispatches
  via account.place_order through the asyncio bridge. Advanced (OCO/OTO/
  bracket) orders still log a clear warning and return None — those need
  NewComplexOrder and will land in a follow-up.
- _submit_orders: multileg path (credit/debit/even/limit/market). Builds
  one NewOrder containing all legs, validates a shared underlying, sends
  a positive price (sign comes from leg actions), and returns a parent
  Order with child orders attached. Parent order_type is normalized to
  Lumibot's allowed values (LIMIT/MARKET) since credit/debit/even are
  wire-level pricing modes, not Lumibot OrderType members.

Mapping helpers:
- _to_occ_symbol: Lumibot Asset -> 21-char OCC option symbol with
  6-char left-padded root (e.g. 'AAPL  260717C00230000').
- _occ_to_asset: inverse parser for read-back.
- _lumi_side_to_tt_action / _lumi_order_type_to_tt / _lumi_tif_to_tt:
  enum bridges between Lumibot and tastytrade.order.

Parsing + read-back:
- _parse_broker_order: PlacedOrder -> Lumibot Order, with parent+child
  expansion for multileg orders. Maps the full Tastytrade OrderStatus
  enum to Lumibot's status taxonomy.
- _pull_broker_order: account.get_order(identifier).
- _pull_broker_all_orders: account.get_live_orders for the working set.

Tests (12 offline pass + live sandbox still gated on creds):
- OCC formatting + round-trip
- Side mapping (equity vs option)
- Equity limit submission (verifies Leg + NewOrder shape)
- Option limit submission (verifies OCC symbol on the leg)
- Multileg credit spread (verifies absolute price + leg action mix)
- Multileg mixed-underlying rejection
- Single-leg + multileg PlacedOrder parsing

Modify, advanced orders, streaming, and market data remain stubbed —
follow-ups.
Wires the broker into Lumibot's strategy executor by adding the
PollingStream-based event loop and replacing the modify-order stub
with a real implementation.

Polling stream:
- _get_stream_object returns PollingStream(polling_interval) with
  polling_interval exposed on the constructor (default 5.0s, matching
  Tradier).
- _register_stream_events binds POLL/NEW/FILLED/CANCELED/ERROR to
  _process_trade_event so the executor sees order lifecycle events.
- _run_stream calls _stream_established then runs the polling loop.
- do_polling pulls live orders, parses each, and dispatches transitions:
    submitted/open  -> NEW_ORDER
    fill            -> FILLED_ORDER (with weighted avg fill price + qty
                       from the PlacedOrder's leg fills)
    canceled        -> CANCELED_ORDER
    error           -> ERROR_ORDER (reject_reason if available)
  Tracked-but-no-longer-at-broker orders are dispatched as cancelled.
  Partial fills are intentionally not dispatched in polling mode (same
  rationale as Tradier: polling will routinely miss them).

Order modification:
- _modify_order rebuilds the leg + NewOrder with the new limit/stop and
  calls account.replace_order. Tastytrade replace returns a new id, so
  we update the local Order's identifier and price fields.

Tests (16 offline pass + live sandbox still gated):
- _get_stream_object returns a PollingStream with the right interval
- _modify_order calls replace_order with the updated price and absorbs
  the broker's new id back onto the local order
- _avg_fill_from_legs computes a size-weighted average across leg fills
- _avg_fill_from_legs returns None when nothing has filled yet

Native AlertStreamer + DXLinkStreamer integration and TastytradeData
market-data methods remain TODO.
Replaces the warning stubs in TastytradeData with REST-based
implementations against the tastytrade SDK's market_data and
instruments modules.

- get_last_price: fetch a market-data snapshot via
  market_data.get_market_data and return mid -> last -> mark, falling
  back to (bid+ask)/2 if those are unset.
- get_quote: same fetch, populated into a Lumibot Quote with
  bid/ask/mid_price/price/sizes/volume/timestamp and the raw payload
  preserved on raw_data['tt_market_data'].
- get_chains: pivot instruments.get_option_chain's
  dict[date, list[Option]] response into Lumibot's nested
  {"Multiplier": 100, "Chains": {"CALL": {date: [strikes]}, "PUT": ...}}
  shape, with strikes sorted per expiration.

Both calls go through the broker's asyncio bridge when one is wired in
(broker passes its _AsyncBridge.run as the runner kwarg). Standalone
construction falls back to asyncio.run.

OCC option-symbol formatting is reused from the broker
(Tastytrade._to_occ_symbol). Index assets are also accepted (mapped to
InstrumentType.INDEX).

Tests (20 offline pass + live sandbox still gated):
- get_last_price prefers mid over last/mark
- get_last_price falls back to (bid+ask)/2 when mid/last/mark all None
- get_quote populates bid/ask/mid/price/sizes/volume/timestamp
- get_chains pivots TT chain to Lumibot's nested shape with sorted
  strikes and lowercase-tolerant option_type matching

get_historical_prices remains a stub — needs the DXLink streamer or a
separate historical-bar source.
Three issues surfaced when running the broker against a live Tastytrade
production account (read-only paths only — balances, positions, market
data, option chains).

1. _strategy_name collision: I had defined _strategy_name as a static
   helper, but Broker base class already uses self._strategy_name as a
   string attribute (set in Broker.__init__). The override broke
   _pull_positions with 'str object is not callable'. Drop the helper
   and use the existing self._strategy_name_from_input(strategy)
   classmethod from the base class.

2. Equity-option positions were skipped: the scaffold _tt_position_to_asset
   only handled instrument_type == 'equity' and warned on options. Now
   that the broker has _occ_to_asset, parse equity options into proper
   Lumibot Asset(OPTION, expiration, strike, right) instances. Verified
   against a real account holding -2 TQQQ 2026-05-08 $63.50 calls — parses
   correctly with negative qty for the short position.

3. Live smoke test was being skipped by the apitest gating in the repo's
   conftest.py (which requires Polygon/Theta creds to run any apitest-
   marked test). The Tastytrade live test only needs Tastytrade creds, so
   drop the apitest marker. The skipif on TASTYTRADE_* env vars still
   gates it correctly.

Validated live (read-only) against production:
- OAuth token refresh
- Account.get + Account.get_balances
- Account.get_positions (equity + equity options)
- market_data.get_market_data (single SPY last price = real)
- instruments.get_option_chain (SPY chain, multiple expirations)

Tastytrade's market-data REST is aggressively rate-limited (429 on two
back-to-back calls). For active polling, callers should debounce or
wait for native DXLinkStreamer integration.
Two real-API failures uncovered when running place/replace/cancel
end-to-end against a live Tastytrade production account.

1. cant_buy_for_credit error on a BUY:

   Tastytrade encodes credit/debit in the SIGN of NewOrder.price
   (negative = debit, positive = credit) and the SDK serializer strips
   abs() before sending while emitting price-effect separately. Sending
   a positive price for a BUY made Tastytrade interpret it as
   "buy for a credit" and reject the order.

   Fix: add _is_debit_action + _sign_single_leg_price helpers and apply
   the right sign in _submit_order / _modify_order. For multileg, sign
   the price by order_type_norm: 'credit' -> positive, 'debit' ->
   negative, 'even' -> 0, 'limit' -> infer from leg net direction
   (raise on mixed-action limit since the sign is ambiguous).

2. order_legs.action: is invalid on an equity BUY:

   Tastytrade's standard order endpoint rejects plain ``Buy`` / ``Sell``
   on equity legs even though those values exist in the OrderAction
   enum (they're for special cases like notional market orders).
   Equity legs need the explicit open/close form.

   Fix: map Lumibot equity sides to Tastytrade open/close actions:
     BUY           -> BUY_TO_OPEN
     SELL          -> SELL_TO_CLOSE
     SELL_SHORT    -> SELL_TO_OPEN
     BUY_TO_COVER  -> BUY_TO_CLOSE
   Plus pass-through for explicit *_TO_* sides.

Validated live (read+write) against a production IRA, market closed,
limit price far from market so it cannot fill:
  STEP 1 place    BUY 1 SPY @ $1.00 LIMIT DAY  -> id 463096620,  Received
  STEP 2 replace  $1.00 -> $1.50               -> id 463096622,  Received
  STEP 3 cancel                                -> id 463096622,  Cancelled
The new id on replace is Tastytrade's normal behavior (cancel-and-new);
_modify_order absorbs the new id back onto the Lumibot Order.

Tests updated to match the new signed-price + open/close-action wire
shape. 20 offline pass.
@3452sdfgsdf
3452sdfgsdf requested a review from grzesir as a code owner May 5, 2026 00:54
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a58579ea-304c-4007-87c2-f1cbe9ed9789

📥 Commits

Reviewing files that changed from the base of the PR and between 3f4717c and 0a30dfc.

📒 Files selected for processing (2)
  • tests/conftest.py
  • tests/test_tastytrade_broker_smoke_apitest.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_tastytrade_broker_smoke_apitest.py

📝 Walkthrough

Walkthrough

Adds a Tastytrade broker adapter and TastytradeData data source that wrap the asynchronous tastytrade SDK via a background asyncio bridge, implement order/position mapping and polling-based reconciliation, export the classes from package namespaces, add the SDK dependency, and include unit + optional live smoke tests.

Changes

Tastytrade Broker and Data Source Integration

Layer / File(s) Summary
External Dependency
requirements.txt
Adds tastytrade>=12.0.0.
Runtime Guard / Imports
lumibot/brokers/tastytrade.py, lumibot/data_sources/tastytrade_data.py
Optional guarded imports for the tastytrade SDK; module-level _TT* symbols set or ImportError raised at runtime.
Async Bridge / Core Runtime
lumibot/brokers/tastytrade.py
_AsyncBridge
Introduces _AsyncBridge: dedicated asyncio loop on a daemon thread with run() and close() to execute SDK coroutines synchronously.
Broker Core Implementation
lumibot/brokers/tastytrade.py (Tastytrade class)
Adds Tastytrade(Broker) with credential resolution (kwargs/config/env), SDK Session creation, account selection, optional TastytradeData wiring, position/balance sync, OCC symbol conversion/parsing, Lumibot↔Tastytrade mappings (side/type/TIF), single-leg and multileg order build/submit/cancel/replace, order parsing (parent/child reconstruction), fill aggregation helpers, polling reconciliation (do_polling()), stream wiring, and __del__ cleanup.
Order Construction & Submission
lumibot/brokers/tastytrade.py (builders/submitters)
Implements _build_new_order, _submit_order (single-leg), _submit_orders (multileg single-parent NewOrder), _finalize_submitted_order, pricing sign normalization, rejection of unsupported advanced orders, and synthetic parent/child Lumibot order construction for multileg.
Read-back / Polling
lumibot/brokers/tastytrade.py (parsers/polling)
Adds Tastytrade→Lumibot status mapping, _parse_broker_order, _pull_broker_all_orders, _avg_fill_from_legs, _filled_qty_from_legs, _get_stream_object, _register_stream_events, and do_polling() to reconcile and dispatch NEW/FILLED/CANCELED/ERROR events; infers cancellations for disappeared active orders.
Data Source Implementation
lumibot/data_sources/tastytrade_data.py
Adds TastytradeData(DataSource) with optional async runner, _await helper, get_last_price, get_quote, and get_chains using SDK snapshot/chain calls; price selection logic (mid → last → mark → bid/ask avg), OCC formatting for options, error logging and safe defaults; get_historical_prices stub.
Package Exports
lumibot/brokers/__init__.py, lumibot/data_sources/__init__.py
Re-exports Tastytrade and TastytradeData at package top-level namespaces.
Tests & Validation
tests/test_tastytrade_broker_smoke_apitest.py, tests/conftest.py
Adds extensive offline/unit tests plus an optional credential-gated live smoke test; registers a tastytrade pytest marker and adjusts test credential gating.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Broker as Tastytrade Broker
    participant Bridge as _AsyncBridge
    participant SDK as Tastytrade SDK
    participant DataSource as TastytradeData
    participant Stream as PollingStream

    Client->>Broker: submit_order(order)
    Broker->>Broker: convert order (OCC, sides, types, TIF)
    Broker->>Bridge: run(SDK.place_order(...))
    Bridge->>SDK: execute coroutine
    SDK-->>Bridge: order response
    Bridge-->>Broker: return result
    Broker->>Stream: dispatch NEW event

    loop Polling Cycle
        Stream->>Broker: do_polling()
        Broker->>Bridge: run(SDK.get_live_orders)
        Bridge->>SDK: execute coroutine
        SDK-->>Bridge: live orders
        Bridge-->>Broker: parsed orders
        Broker->>Broker: reconcile statuses, compute fills
        Broker->>Stream: dispatch FILLED/CANCELED/ERROR
    end

    Client->>DataSource: get_quote(asset)
    DataSource->>Bridge: run(SDK.snapshot)
    Bridge->>SDK: execute coroutine
    SDK-->>Bridge: market data
    Bridge-->>DataSource: return data
    DataSource-->>Client: Quote
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

"I hopped through code with whiskers bright,
Async bridges humming through the night,
OCC notes tucked in tidy rows,
Orders, fills, and polling flows,
A tasty broker — carrot-juiced delight!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a Tastytrade broker adapter with support for equities, options, and multileg spreads, which aligns with the substantial new module additions and objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Pylint (4.0.5)
tests/conftest.py

************* Module pylintrc
pylintrc:1:0: F0011: error while parsing the configuration: File contains no section headers.
file: 'pylintrc', line: 1
'known-third-party=lumibot' (config-parse-error)
[
{
"type": "convention",
"module": "tests.conftest",
"obj": "",
"line": 31,
"column": 0,
"endLine": null,
"endColumn": null,
"path": "tests/conftest.py",
"symbol": "line-too-long",
"message": "Line too long (101/100)",
"message-id": "C0301"
},
{
"type": "convention",
"module": "tests.conftest",
"obj": "",
"line": 87,
"column": 0,
"endLine": null,
"endColumn": null,
"path": "tests/conftest.py",
"symbol": "line-too-long",
"message": "Line too long (127/100)",
"message-id": "C0301"
},
{
"type": "convention",
"module": "tests.conftest",
"obj": "",
"line": 88,
"c

... [truncated 15483 characters] ...

"module": "tests.conftest",
"obj": "",
"line": 12,
"column": 0,
"endLine": 12,
"endColumn": 24,
"path": "tests/conftest.py",
"symbol": "wrong-import-order",
"message": "standard import "pathlib.Path" should be placed before third party import "pytest"",
"message-id": "C0411"
},
{
"type": "convention",
"module": "tests.conftest",
"obj": "",
"line": 14,
"column": 0,
"endLine": 14,
"endColumn": 35,
"path": "tests/conftest.py",
"symbol": "wrong-import-order",
"message": "standard import "collections.defaultdict" should be placed before third party imports "pytest", "dotenv.load_dotenv"",
"message-id": "C0411"
}
]

tests/test_tastytrade_broker_smoke_apitest.py

************* Module pylintrc
pylintrc:1:0: F0011: error while parsing the configuration: File contains no section headers.
file: 'pylintrc', line: 1
'known-third-party=lumibot' (config-parse-error)
[
{
"type": "convention",
"module": "tests.test_tastytrade_broker_smoke_apitest",
"obj": "_make_broker",
"line": 23,
"column": 4,
"endLine": 23,
"endColumn": 52,
"path": "tests/test_tastytrade_broker_smoke_apitest.py",
"symbol": "import-outside-toplevel",
"message": "Import outside toplevel (lumibot.brokers.tastytrade)",
"message-id": "C0415"
},
{
"type": "convention",
"module": "tests.test_tastytrade_broker_smoke_apitest",
"obj": "test_missing_credentials_raises",
"line": 53,
"column": 4,
"endLine": 53,
"endColumn": 53,
"path": "tests/test_tastytrade_broker_smoke_apitest.py",
"symbol": "import-outside-toplevel",

... [truncated 57444 characters] ...

est_live_sandbox_balances_and_positions",
"line": 808,
"column": 8,
"endLine": 808,
"endColumn": 28,
"path": "tests/test_tastytrade_broker_smoke_apitest.py",
"symbol": "protected-access",
"message": "Access to a protected member _async_bridge of a client class",
"message-id": "W0212"
},
{
"type": "warning",
"module": "tests.test_tastytrade_broker_smoke_apitest",
"obj": "test_live_sandbox_balances_and_positions",
"line": 800,
"column": 14,
"endLine": 800,
"endColumn": 29,
"path": "tests/test_tastytrade_broker_smoke_apitest.py",
"symbol": "unused-variable",
"message": "Unused variable 'positions_value'",
"message-id": "W0612"
}
]

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lumibot/brokers/tastytrade.py`:
- Around line 103-107: The run method's call to future.result(timeout=...) can
raise (e.g., TimeoutError) but currently leaves the coroutine running on the
background loop; update TastytradeBroker.run (the run method) to cancel the
pending asyncio.Future when result() raises or times out: wrap
future.result(...) in try/except, and on any Exception (especially TimeoutError)
call future.cancel() (and optionally await/handle cancellation on the background
loop), then re-raise the original exception so the caller sees the failure while
ensuring the background task is cancelled.
- Around line 157-158: The current assignment if is_test is None and "SANDBOX"
in config: is_test = bool(config.get("SANDBOX")) wrongly treats string values
like "false" or "0" as True; change the logic in tastytrade.py where is_test is
set to explicitly parse string config values: when config.get("SANDBOX") is a
str, normalize to lower() and map accepted true tokens ("true","1","yes","on")
to True and false tokens ("false","0","no","off") to False, otherwise for
non-str values use the existing bool() conversion; update the is_test assignment
and related branch to use this parsed result.
- Around line 719-752: _detect multileg orders early in _modify_order and reject
them instead of rebuilding a single leg: before calling self._build_leg(order)
check the parent order for multiple legs (e.g., inspect order.legs or an
equivalent attribute/collection on the Order object) and if len(legs) > 1 log an
error or raise ValueError and return (do not proceed to build or replace);
update the error message to mention multileg replacements are not supported and
reference _modify_order and _build_leg so the failure is immediate and explicit
for spread orders.
- Around line 840-857: The equity parse path in _leg_to_lumi_side currently only
maps plain "buy"/"sell", so explicit Tastytrade equity actions like "buy to
open", "sell to open", "buy to close", and "sell to close" fall through to the
default BUY; update the non-option mapping in _leg_to_lumi_side to include those
explicit action strings and map them to the correct Order.OrderSide (e.g., any
"buy ..." -> Order.OrderSide.BUY, any "sell ..." -> Order.OrderSide.SELL) using
the same constants already used in the function so explicit equity read-backs
are parsed correctly.
- Around line 965-969: The bug is a race where orders that are reported
filled/rejected by get_live_orders() are dispatched via
_safe_stream_dispatch(FILLED_ORDER/ERROR_ORDER) but stored.status isn’t updated
synchronously, so on the next poll the missing-order check (which uses
order.is_active()) misclassifies them as canceled; to fix, when you detect a
filled or error order in _pull_broker_all_orders/_process_live_orders (where you
call _safe_stream_dispatch with FILLED_ORDER or ERROR_ORDER), immediately update
the stored order state (stored.status = OrderStatus.FILLED or OrderStatus.ERROR
or mark stored as inactive) before returning/continuing so order.is_active()
reflects the new state and the missing-order branch won’t treat it as canceled;
reference _pull_broker_all_orders, get_live_orders, _safe_stream_dispatch,
FILLED_ORDER, ERROR_ORDER, stored.status, and order.is_active when applying the
change.

In `@tests/test_tastytrade_broker_smoke_apitest.py`:
- Around line 653-665: Update the test decorator to mark it as an API smoke test
(add `@pytest.mark.apitest` above the existing `@pytest.mark.skipif`) and
instantiate the broker in sandbox mode by passing is_test=True to Tastytrade
(modify the broker creation in test_live_sandbox_balances_and_positions from
Tastytrade(connect_stream=False) to Tastytrade(connect_stream=False,
is_test=True)) so the test is explicitly opt-in and guaranteed to hit the
sandbox; keep the existing skipif credential guard intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9aa9857-17fe-497d-985a-d1193bab592d

📥 Commits

Reviewing files that changed from the base of the PR and between 07d280b and 2605625.

📒 Files selected for processing (6)
  • lumibot/brokers/__init__.py
  • lumibot/brokers/tastytrade.py
  • lumibot/data_sources/__init__.py
  • lumibot/data_sources/tastytrade_data.py
  • requirements.txt
  • tests/test_tastytrade_broker_smoke_apitest.py

Comment thread lumibot/brokers/tastytrade.py Outdated
Comment thread lumibot/brokers/tastytrade.py Outdated
Comment thread lumibot/brokers/tastytrade.py
Comment thread lumibot/brokers/tastytrade.py
Comment thread lumibot/brokers/tastytrade.py
Comment thread tests/test_tastytrade_broker_smoke_apitest.py Outdated
Five small correctness fixes from code review.

1. _AsyncBridge.run: cancel pending future on exception.
   future.result(timeout=...) leaving the underlying coroutine running on
   the background loop is a leak — most visibly on TimeoutError. Wrap in
   try/except, call future.cancel() on any exception, and re-raise so the
   caller still sees the failure.

2. SANDBOX parsing: bool('false') == True.
   Config path was bool(config.get('SANDBOX')), which lights up cert env
   for any non-empty string including 'false'. Add a small _parse_truthy
   helper used by both the config and env-var paths; treat the standard
   true/false token set explicitly.

3. _modify_order rejects multileg explicitly.
   _build_leg only constructs a single leg from the parent Order, so
   calling replace_order on a spread would silently submit a one-legged
   replacement. Detect order_class == MULTILEG (or len(child_orders) > 1)
   up front, log a clear error, and return None. Cancel-and-resubmit is
   the workaround until multileg replace lands.

4. _leg_to_lumi_side parses explicit equity actions.
   Tastytrade requires the open/close form on equity legs (BUY_TO_OPEN /
   SELL_TO_CLOSE / etc.) and that's what comes back when reading orders.
   The previous equity dict only mapped plain 'buy'/'sell' so reads
   silently fell through to OrderSide.BUY. Map all four explicit forms
   to the matching Lumibot OrderSide.

5. do_polling updates stored.status synchronously on terminal events.
   _safe_stream_dispatch enqueues an event the stream worker handles
   asynchronously — until that event is processed, stored.status is
   stale. The next poll cycle then sees the order missing from
   get_live_orders, calls is_active() which still returns True, and
   re-dispatches CANCELED on top of an already-FILLED/ERROR'd order.
   Set stored.status = FILLED / CANCELED / ERROR right after dispatch so
   is_active() reflects the new state on the next pass.

Tests: 4 new offline tests cover findings 1-4 (24 offline pass). Finding
5 has no dedicated unit test — the change is three inline status
assignments inside do_polling and a meaningful test would require
mocking out the strategy executor's order tracking machinery, which is
disproportionate. Verified by inspection.

Skipped: one CodeRabbit suggestion to add @pytest.mark.apitest and
is_test=True to the live smoke test. The apitest marker would re-trigger
Lumibot's conftest auto-skip on Polygon/Theta credentials (see commit
e4dace73 for why we removed it). is_test=True would force cert env even
when the user provides production creds, breaking the prod read-only
flow used during validation. The existing TASTYTRADE_SANDBOX env var
already controls cert-vs-prod selection.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
tests/test_tastytrade_broker_smoke_apitest.py (1)

774-796: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Live smoke test still lacks @pytest.mark.apitest and sandbox enforcement.

The test runs whenever the three credential env vars are set — even with production credentials — because there is no TASTYTRADE_SANDBOX guard and no is_test=True passed to the broker constructor. This was flagged in a prior review and remains unaddressed.

Specific problems:

  • Missing @pytest.mark.apitest decorator (coding-guideline requirement).
  • Tastytrade(connect_stream=False) can connect to the live production API when run with production credentials.
  • The skipif condition should additionally require TASTYTRADE_SANDBOX to be set to a truthy value.
🛠️ Proposed fix
+@pytest.mark.apitest
 `@pytest.mark.skipif`(
     not all(os.environ.get(k) for k in (
         "TASTYTRADE_CLIENT_SECRET",
         "TASTYTRADE_REFRESH_TOKEN",
         "TASTYTRADE_ACCOUNT_NUMBER",
-    )),
-    reason="Tastytrade credentials not configured.",
+    )) or os.environ.get("TASTYTRADE_SANDBOX", "").strip().lower() not in ("1", "true", "yes", "y"),
+    reason="Tastytrade sandbox credentials not configured.",
 )
 def test_live_sandbox_balances_and_positions():
     """Hit the sandbox API for balances + positions; expects no exceptions."""
     from lumibot.brokers.tastytrade import Tastytrade
 
-    broker = Tastytrade(connect_stream=False)
+    broker = Tastytrade(connect_stream=False, is_test=True)

As per coding guidelines, "Prefer existing pytest markers (apitest, acceptance_backtest, etc.) over creating new environment variables to skip tests."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_tastytrade_broker_smoke_apitest.py` around lines 774 - 796, Add
the missing apitest marker, ensure the test only runs against the sandbox by
requiring TASTYTRADE_SANDBOX to be truthy in the pytest.mark.skipif condition,
and instantiate the broker in test_live_sandbox_balances_and_positions with
is_test=True (e.g., Tastytrade(connect_stream=False, is_test=True)) so the
Tastytrade class uses sandbox endpoints; specifically update the skipif to check
os.environ.get("TASTYTRADE_SANDBOX") along with the three credential vars and
add `@pytest.mark.apitest` above the test function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_tastytrade_broker_smoke_apitest.py`:
- Line 399: The list comprehension uses an ambiguous single-letter variable name
`l` in "actions = [l.action for l in new_order.legs]" which triggers Ruff E741;
change the loop variable to a clear name such as `leg` (e.g., "actions =
[leg.action for leg in new_order.legs]") so the variable is unambiguous and Ruff
will not flag it, updating any other occurrences of `l` in that expression if
present.
- Around line 93-113: Replace the non-thread-safe asyncio.Event with a
threading.Event for cross-thread signalling: change the `cancelled =
asyncio.Event()` declaration to use `threading.Event()` and ensure
`bridge._loop.call_soon_threadsafe(cancelled.set)` remains the setter from the
loop thread while the test's `cancelled.is_set()` check runs on the main thread;
also remove the dead `deadline = asyncio.get_event_loop().time() + 1.0 if False
else None` line (it's unused). Update imports as needed and keep `_slow()` and
the `with pytest.raises(concurrent.futures.TimeoutError): bridge.run(_slow(),
timeout=0.05)` logic intact.

---

Duplicate comments:
In `@tests/test_tastytrade_broker_smoke_apitest.py`:
- Around line 774-796: Add the missing apitest marker, ensure the test only runs
against the sandbox by requiring TASTYTRADE_SANDBOX to be truthy in the
pytest.mark.skipif condition, and instantiate the broker in
test_live_sandbox_balances_and_positions with is_test=True (e.g.,
Tastytrade(connect_stream=False, is_test=True)) so the Tastytrade class uses
sandbox endpoints; specifically update the skipif to check
os.environ.get("TASTYTRADE_SANDBOX") along with the three credential vars and
add `@pytest.mark.apitest` above the test function.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05c37abd-c54b-4d95-8cce-5c9fb3409162

📥 Commits

Reviewing files that changed from the base of the PR and between 2605625 and 3471aa8.

📒 Files selected for processing (2)
  • lumibot/brokers/tastytrade.py
  • tests/test_tastytrade_broker_smoke_apitest.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • lumibot/brokers/tastytrade.py

Comment thread tests/test_tastytrade_broker_smoke_apitest.py Outdated
Comment thread tests/test_tastytrade_broker_smoke_apitest.py Outdated
Tod Kemper added 2 commits May 4, 2026 21:33
Address two follow-up review comments on the test file:

1. test_async_bridge_cancels_future_on_timeout used asyncio.Event to
   signal from the background asyncio thread back to the main test
   thread. asyncio.Event is bound to a single event loop and isn't
   thread-safe; the right primitive for cross-thread signalling is
   threading.Event. Swap it in. Also drop a stray dead expression
   (`deadline = ... if False else None`) that was a holdover from an
   earlier draft.

2. test_submit_orders_multileg_credit_spread had `for l in
   new_order.legs` which trips Ruff E741 ("ambiguous variable name").
   Rename to `for leg in ...`.

24 offline tests still pass.
Resolves the deferred review comment on test_live_sandbox_balances_and_
positions: add the explicit @pytest.mark.apitest decoration AND force
is_test=True per CodeRabbit's recommendation, while avoiding the
collateral damage we documented earlier (apitest alone would skip the
test on missing Polygon/ThetaData creds).

conftest.py:
- Register a new tastytrade marker class, mirroring the existing ibkr
  marker for tests that hit a non-Polygon/Theta external API.
- Extend the requires_ibkr branch in pytest_runtest_setup to also
  short-circuit Polygon/Theta credential checks when the tastytrade
  marker is present.

test_live_sandbox_balances_and_positions:
- Add @pytest.mark.apitest + @pytest.mark.tastytrade so it's discoverable
  as an API test without inheriting the polygon/thetadata gating.
- Construct broker with is_test=True so the test always lands on
  Tastytrade's certification (sandbox) env regardless of how the user
  set TASTYTRADE_SANDBOX — prevents accidental prod-account hits in CI.

24 offline tests still pass; live test still gated by skipif on
TASTYTRADE_* env credentials.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_tastytrade_broker_smoke_apitest.py`:
- Around line 5-7: The docstring says there is an `apitest`-marked test but the
bottom test only has `@pytest.mark.skipif`; either add the missing marker or
update the docstring. Fix by adding the appropriate pytest marker (e.g.,
annotate the bottom test with `@pytest.mark.apitest` or the agreed-upon
`@pytest.mark.tastytrade_api` and ensure a matching conftest registration) OR
change the module docstring to state the test is gated by `@pytest.mark.skipif`
instead of claiming an `apitest` marker; locate the bottom test (the one
currently decorated with `@pytest.mark.skipif`) and the module docstring to make
the consistent change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e37f06fe-e0c8-435d-8cc2-cc1423bb5f45

📥 Commits

Reviewing files that changed from the base of the PR and between 3471aa8 and 3f4717c.

📒 Files selected for processing (1)
  • tests/test_tastytrade_broker_smoke_apitest.py

Comment thread tests/test_tastytrade_broker_smoke_apitest.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant