TradingAgents/.planning/phases/01-tradier-data-layer/01-02-PLAN.md

25 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-tradier-data-layer 02 execute 2
01-01
tradingagents/dataflows/interface.py
tradingagents/default_config.py
tradingagents/agents/utils/options_tools.py
.env.example
tests/unit/data/test_tradier.py
tests/conftest.py
true
DATA-08
truths artifacts key_links
Tradier is registered as a vendor in VENDOR_LIST
options_chain category exists in TOOLS_CATEGORIES with get_options_chain and get_options_expirations tools
VENDOR_METHODS maps get_options_chain and get_options_expirations to Tradier implementations
DEFAULT_CONFIG data_vendors includes options_chain: tradier
route_to_vendor catches TradierRateLimitError for vendor fallback
@tool decorated functions exist for options chain retrieval
All unit tests pass with mocked Tradier API responses
path provides contains
tradingagents/dataflows/interface.py Vendor routing with Tradier and options_chain category options_chain
path provides contains
tradingagents/default_config.py Default config with options_chain vendor options_chain
path provides exports
tradingagents/agents/utils/options_tools.py LangChain @tool functions for options data
get_options_chain
get_options_expirations
path provides min_lines
tests/unit/data/test_tradier.py Unit tests for all DATA requirements 100
from to via pattern
tradingagents/dataflows/interface.py tradingagents/dataflows/tradier.py import get_options_chain, get_options_expirations from .tradier import
from to via pattern
tradingagents/agents/utils/options_tools.py tradingagents/dataflows/interface.py route_to_vendor call route_to_vendor
from to via pattern
tradingagents/dataflows/interface.py tradingagents/dataflows/tradier_common.py import TradierRateLimitError for fallback catch TradierRateLimitError
Integrate Tradier into the existing vendor routing system, create @tool functions for LLM agents, and write comprehensive unit tests covering all Phase 1 requirements.

Purpose: Without vendor registration, no agent can access Tradier data. Without tests, we cannot verify correctness. This plan wires the Tradier module into the system and proves it works. Output: Updated interface.py and default_config.py, new options_tools.py, comprehensive test suite in tests/unit/data/test_tradier.py.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-tradier-data-layer/01-CONTEXT.md @.planning/phases/01-tradier-data-layer/01-RESEARCH.md @.planning/phases/01-tradier-data-layer/01-01-SUMMARY.md

From tradingagents/dataflows/tradier.py (created in Plan 01):

@dataclass
class OptionsContract:
    symbol: str; underlying: str; option_type: str; strike: float
    expiration_date: str; bid: float; ask: float; last: float
    volume: int; open_interest: int
    delta: float | None; gamma: float | None; theta: float | None
    vega: float | None; rho: float | None; phi: float | None
    bid_iv: float | None; mid_iv: float | None; ask_iv: float | None
    smv_vol: float | None; greeks_updated_at: str | None

@dataclass
class OptionsChain:
    underlying: str; fetch_timestamp: str; expirations: list[str]
    contracts: list[OptionsContract]
    def to_dataframe(self) -> pd.DataFrame: ...
    def filter_by_dte(self, min_dte: int = 0, max_dte: int = 50) -> "OptionsChain": ...

def get_options_expirations(symbol: str, min_dte: int = 0, max_dte: int = 50) -> list[str]: ...
def get_options_chain(symbol: str, min_dte: int = 0, max_dte: int = 50) -> str: ...
def get_options_chain_structured(symbol: str, min_dte: int = 0, max_dte: int = 50) -> OptionsChain: ...
def clear_options_cache(): ...

From tradingagents/dataflows/tradier_common.py (created in Plan 01):

class TradierRateLimitError(Exception): pass
def get_api_key() -> str: ...
def get_base_url() -> str: ...
def make_tradier_request(path: str, params: dict | None = None) -> dict: ...
def make_tradier_request_with_retry(path: str, params: dict | None = None, max_retries: int = 3) -> dict: ...

From tradingagents/dataflows/interface.py (existing):

TOOLS_CATEGORIES = { "core_stock_apis": ..., "technical_indicators": ..., "fundamental_data": ..., "news_data": ... }
VENDOR_LIST = ["yfinance", "alpha_vantage"]
VENDOR_METHODS = { "get_stock_data": {...}, ... }
def route_to_vendor(method: str, *args, **kwargs): ...
# Currently catches only AlphaVantageRateLimitError in fallback loop

From tradingagents/default_config.py (existing):

DEFAULT_CONFIG = {
    "data_vendors": {
        "core_stock_apis": "yfinance",
        "technical_indicators": "yfinance",
        "fundamental_data": "yfinance",
        "news_data": "yfinance",
    },
    "tool_vendors": {},
}

From tradingagents/agents/utils/core_stock_tools.py (pattern reference):

from langchain_core.tools import tool
from typing import Annotated
from tradingagents.dataflows.interface import route_to_vendor

@tool
def get_stock_data(symbol: Annotated[str, "ticker symbol"], ...) -> str:
    return route_to_vendor("get_stock_data", symbol, start_date, end_date)
Task 1: Register Tradier in vendor routing and update default config tradingagents/dataflows/interface.py, tradingagents/default_config.py, .env.example, tradingagents/agents/utils/options_tools.py - tradingagents/dataflows/interface.py - tradingagents/default_config.py - tradingagents/agents/utils/core_stock_tools.py - tradingagents/dataflows/tradier.py - tradingagents/dataflows/tradier_common.py - .env.example **A. Update `tradingagents/dataflows/interface.py`:**
  1. Add imports at top of file (after existing vendor imports):

    from .tradier import (
        get_options_chain as get_tradier_options_chain,
        get_options_expirations as get_tradier_options_expirations,
    )
    from .tradier_common import TradierRateLimitError
    
  2. Add "options_chain" category to TOOLS_CATEGORIES dict:

    "options_chain": {
        "description": "Options chain data with Greeks and IV",
        "tools": [
            "get_options_chain",
            "get_options_expirations",
        ]
    }
    
  3. Add "tradier" to VENDOR_LIST:

    VENDOR_LIST = ["yfinance", "alpha_vantage", "tradier"]
    
  4. Add options methods to VENDOR_METHODS dict:

    "get_options_chain": {
        "tradier": get_tradier_options_chain,
    },
    "get_options_expirations": {
        "tradier": get_tradier_options_expirations,
    },
    
  5. Update route_to_vendor() fallback exception catch to also catch TradierRateLimitError. Change line:

    except AlphaVantageRateLimitError:
    

    to:

    except (AlphaVantageRateLimitError, TradierRateLimitError):
    

B. Update tradingagents/default_config.py:

Add to the "data_vendors" dict inside DEFAULT_CONFIG:

"options_chain": "tradier",       # Options: tradier

C. Update .env.example:

Add after the existing API keys section:

# Options Data Providers
TRADIER_API_KEY=
TRADIER_SANDBOX=false

D. Create tradingagents/agents/utils/options_tools.py:

Following the core_stock_tools.py pattern exactly:

from langchain_core.tools import tool
from typing import Annotated
from tradingagents.dataflows.interface import route_to_vendor


@tool
def get_options_chain(
    symbol: Annotated[str, "ticker symbol of the company"],
    min_dte: Annotated[int, "minimum days to expiration"] = 0,
    max_dte: Annotated[int, "maximum days to expiration"] = 50,
) -> str:
    """
    Retrieve options chain data with Greeks and IV for a given ticker symbol.
    Returns strikes, expirations, bid/ask, volume, OI, 1st-order Greeks
    (Delta, Gamma, Theta, Vega, Rho), and implied volatility (bid_iv,
    mid_iv, ask_iv, smv_vol) filtered by DTE range.

    Args:
        symbol (str): Ticker symbol of the company, e.g. AAPL, TSLA
        min_dte (int): Minimum days to expiration (default 0)
        max_dte (int): Maximum days to expiration (default 50)
    Returns:
        str: A formatted dataframe containing options chain data with Greeks and IV.
    """
    return route_to_vendor("get_options_chain", symbol, min_dte, max_dte)


@tool
def get_options_expirations(
    symbol: Annotated[str, "ticker symbol of the company"],
    min_dte: Annotated[int, "minimum days to expiration"] = 0,
    max_dte: Annotated[int, "maximum days to expiration"] = 50,
) -> str:
    """
    Retrieve available options expiration dates for a given ticker symbol,
    filtered by DTE range.

    Args:
        symbol (str): Ticker symbol of the company, e.g. AAPL, TSLA
        min_dte (int): Minimum days to expiration (default 0)
        max_dte (int): Maximum days to expiration (default 50)
    Returns:
        str: Comma-separated list of expiration dates (YYYY-MM-DD format).
    """
    result = route_to_vendor("get_options_expirations", symbol, min_dte, max_dte)
    if isinstance(result, list):
        return ", ".join(result)
    return str(result)

Default DTE range 0-50 per D-04. Docstrings are LLM-readable per project conventions. uv run python -c " from tradingagents.dataflows.interface import TOOLS_CATEGORIES, VENDOR_LIST, VENDOR_METHODS assert 'options_chain' in TOOLS_CATEGORIES, 'options_chain not in TOOLS_CATEGORIES' assert 'tradier' in VENDOR_LIST, 'tradier not in VENDOR_LIST' assert 'get_options_chain' in VENDOR_METHODS, 'get_options_chain not in VENDOR_METHODS' assert 'get_options_expirations' in VENDOR_METHODS, 'get_options_expirations not in VENDOR_METHODS' from tradingagents.default_config import DEFAULT_CONFIG assert DEFAULT_CONFIG['data_vendors'].get('options_chain') == 'tradier', 'options_chain not in default config' from tradingagents.agents.utils.options_tools import get_options_chain, get_options_expirations print('ALL CHECKS PASSED') " <acceptance_criteria> - tradingagents/dataflows/interface.py contains "options_chain" in TOOLS_CATEGORIES - tradingagents/dataflows/interface.py contains "tradier" in VENDOR_LIST - tradingagents/dataflows/interface.py contains "get_options_chain" in VENDOR_METHODS - tradingagents/dataflows/interface.py contains "get_options_expirations" in VENDOR_METHODS - tradingagents/dataflows/interface.py contains TradierRateLimitError in the except clause of route_to_vendor - tradingagents/dataflows/interface.py contains from .tradier import - tradingagents/dataflows/interface.py contains from .tradier_common import TradierRateLimitError - tradingagents/default_config.py contains "options_chain": "tradier" - .env.example contains TRADIER_API_KEY= - .env.example contains TRADIER_SANDBOX=false - tradingagents/agents/utils/options_tools.py exists - tradingagents/agents/utils/options_tools.py contains @tool (at least twice) - tradingagents/agents/utils/options_tools.py contains route_to_vendor("get_options_chain" - tradingagents/agents/utils/options_tools.py contains route_to_vendor("get_options_expirations" - tradingagents/agents/utils/options_tools.py contains min_dte and max_dte parameters with default 0 and 50 (D-04) - uv run python -c "from tradingagents.agents.utils.options_tools import get_options_chain" exits 0 </acceptance_criteria> Tradier registered in vendor routing (VENDOR_LIST, TOOLS_CATEGORIES, VENDOR_METHODS). DEFAULT_CONFIG has options_chain: tradier. route_to_vendor catches TradierRateLimitError. Two @tool functions created following core_stock_tools.py pattern. .env.example updated with TRADIER_API_KEY and TRADIER_SANDBOX.

Task 2: Create comprehensive unit tests for all Phase 1 requirements tests/unit/data/test_tradier.py, tests/conftest.py - tradingagents/dataflows/tradier.py - tradingagents/dataflows/tradier_common.py - tradingagents/dataflows/interface.py - tradingagents/agents/utils/options_tools.py - tests/test_ticker_symbol_handling.py - TestGetExpirations (DATA-02): mock Tradier /expirations response with 5 dates, verify DTE filter returns only dates within range. Test single-date string normalization (Pitfall 5). - TestGetOptionsChain (DATA-01): mock Tradier /chains response with 3 contracts, verify OptionsChain has correct underlying, expirations, and contract count. Test single-contract dict normalization (Pitfall 2). - TestGreeksPresent (DATA-03): mock response with greeks object, verify OptionsContract has delta, gamma, theta, vega, rho values and greeks_updated_at timestamp. - TestGreeksAbsent (Pitfall 1): mock response with greeks: null, verify OptionsContract has None for all Greeks fields without crashing. - TestIVPresent (DATA-04): mock response with greeks object, verify OptionsContract has bid_iv, mid_iv, ask_iv, smv_vol values. - TestDTEFilter (DATA-05): create OptionsChain with contracts at various DTEs, call filter_by_dte(30, 60), verify only contracts in range remain. - TestVendorRegistration (DATA-08): verify "tradier" in VENDOR_LIST, "options_chain" in TOOLS_CATEGORIES, get_options_chain and get_options_expirations in VENDOR_METHODS. - TestRateLimitDetection: mock 429 response, verify TradierRateLimitError raised. Mock response with X-Ratelimit-Available: 0, verify TradierRateLimitError raised. - TestSessionCache: patch the underlying HTTP/request mock; call `get_options_chain_structured("AAPL")` twice — assert `mock.call_count == 1` after the second call (second read is cache hit). Call `clear_options_cache()`, then `get_options_chain_structured("AAPL")` again — assert `mock.call_count == 2` (or increased by exactly one vs post-clear baseline). - TestSandboxURL: set TRADIER_SANDBOX=true, verify get_base_url returns sandbox URL. **A. Install pytest if not present:** ```bash uv add --dev pytest>=8.0 ```

B. Create tests/conftest.py with shared Tradier API mock fixtures:

import pytest
from datetime import date, timedelta
from unittest.mock import patch, MagicMock

def _iso_days_out(days: int) -> str:
    """Expiration string relative to today so DTE assertions never go stale."""
    return (date.today() + timedelta(days=days)).isoformat()

MOCK_EXPIRATIONS_RESPONSE = {
    "expirations": {
        "date": [_iso_days_out(d) for d in (7, 14, 21, 28, 45)]
    }
}

MOCK_SINGLE_EXPIRATION_RESPONSE = {
    "expirations": {
        "date": _iso_days_out(21)
    }
}

MOCK_CHAIN_RESPONSE = {
    "options": {
        "option": [
            {
                "symbol": "AAPL260417C00170000",
                "underlying": "AAPL",
                "option_type": "call",
                "strike": 170.0,
                "expiration_date": _iso_days_out(20),
                "bid": 5.10,
                "ask": 5.30,
                "last": 5.20,
                "volume": 1234,
                "open_interest": 5678,
                "greeks": {
                    "delta": 0.55,
                    "gamma": 0.04,
                    "theta": -0.08,
                    "vega": 0.25,
                    "rho": 0.03,
                    "phi": -0.02,
                    "bid_iv": 0.28,
                    "mid_iv": 0.29,
                    "ask_iv": 0.30,
                    "smv_vol": 0.285,
                    "updated_at": "2026-04-01 12:00:00"
                }
            },
            {
                "symbol": "AAPL260417P00170000",
                "underlying": "AAPL",
                "option_type": "put",
                "strike": 170.0,
                "expiration_date": _iso_days_out(20),
                "bid": 3.40,
                "ask": 3.60,
                "last": 3.50,
                "volume": 890,
                "open_interest": 2345,
                "greeks": {
                    "delta": -0.45,
                    "gamma": 0.04,
                    "theta": -0.07,
                    "vega": 0.25,
                    "rho": -0.02,
                    "phi": 0.02,
                    "bid_iv": 0.27,
                    "mid_iv": 0.28,
                    "ask_iv": 0.29,
                    "smv_vol": 0.280,
                    "updated_at": "2026-04-01 12:00:00"
                }
            },
            {
                "symbol": "AAPL260417C00175000",
                "underlying": "AAPL",
                "option_type": "call",
                "strike": 175.0,
                "expiration_date": _iso_days_out(20),
                "bid": 2.80,
                "ask": 3.00,
                "last": 2.90,
                "volume": 567,
                "open_interest": 1234,
                "greeks": {
                    "delta": 0.40,
                    "gamma": 0.05,
                    "theta": -0.09,
                    "vega": 0.24,
                    "rho": 0.02,
                    "phi": -0.01,
                    "bid_iv": 0.30,
                    "mid_iv": 0.31,
                    "ask_iv": 0.32,
                    "smv_vol": 0.305,
                    "updated_at": "2026-04-01 12:00:00"
                }
            }
        ]
    }
}

MOCK_CHAIN_NO_GREEKS_RESPONSE = {
    "options": {
        "option": [
            {
                "symbol": "AAPL260417C00170000",
                "underlying": "AAPL",
                "option_type": "call",
                "strike": 170.0,
                "expiration_date": _iso_days_out(20),
                "bid": 5.10,
                "ask": 5.30,
                "last": 5.20,
                "volume": 1234,
                "open_interest": 5678,
                "greeks": None
            }
        ]
    }
}

MOCK_SINGLE_CONTRACT_RESPONSE = {
    "options": {
        "option": {
            "symbol": "AAPL260417C00170000",
            "underlying": "AAPL",
            "option_type": "call",
            "strike": 170.0,
            "expiration_date": _iso_days_out(20),
            "bid": 5.10,
            "ask": 5.30,
            "last": 5.20,
            "volume": 1234,
            "open_interest": 5678,
            "greeks": {
                "delta": 0.55, "gamma": 0.04, "theta": -0.08,
                "vega": 0.25, "rho": 0.03, "phi": -0.02,
                "bid_iv": 0.28, "mid_iv": 0.29, "ask_iv": 0.30,
                "smv_vol": 0.285, "updated_at": "2026-04-01 12:00:00"
            }
        }
    }
}

C. Create tests/unit/data/test_tradier.py with test classes:

  1. TestGetExpirations (DATA-02): patch tradingagents.dataflows.tradier_common.make_tradier_request to return MOCK_EXPIRATIONS_RESPONSE. Call get_options_expirations("AAPL", 0, 50). Assert result is a list of strings. Assert all dates are within 0-50 DTE of today. Test with MOCK_SINGLE_EXPIRATION_RESPONSE to verify Pitfall 5 normalization.

  2. TestGetOptionsChain (DATA-01): patch make_tradier_request_with_retry to return mock expirations then mock chain. Call get_options_chain_structured("AAPL"). Assert .underlying == "AAPL". Assert len(.contracts) == 3. Assert .contracts[0].bid == 5.10. Assert .contracts[0].volume == 1234. Assert .contracts[0].open_interest == 5678. Test single-contract normalization with MOCK_SINGLE_CONTRACT_RESPONSE.

  3. TestGreeksPresent (DATA-03): use contract from mock chain response. Assert contract.delta == 0.55. Assert contract.gamma == 0.04. Assert contract.theta == -0.08. Assert contract.vega == 0.25. Assert contract.rho == 0.03. Assert contract.greeks_updated_at == "2026-04-01 12:00:00".

  4. TestGreeksAbsent (Pitfall 1): use MOCK_CHAIN_NO_GREEKS_RESPONSE. Assert contract.delta is None. Assert contract.gamma is None. Assert no exception raised.

  5. TestIVPresent (DATA-04): use contract from mock chain. Assert contract.bid_iv == 0.28. Assert contract.mid_iv == 0.29. Assert contract.ask_iv == 0.30. Assert contract.smv_vol == 0.285.

  6. TestDTEFilter (DATA-05): create OptionsChain with contracts at various known expiration_dates. Call chain.filter_by_dte(10, 30). Assert only contracts within 10-30 DTE remain. Assert returned OptionsChain.expirations matches filtered contracts.

  7. TestVendorRegistration (DATA-08): import TOOLS_CATEGORIES, VENDOR_LIST, VENDOR_METHODS from interface. Assert "tradier" in VENDOR_LIST. Assert "options_chain" in TOOLS_CATEGORIES. Assert "get_options_chain" in VENDOR_METHODS. Assert "tradier" in VENDOR_METHODS["get_options_chain"].

  8. TestRateLimitDetection: mock requests.get to return response with status 429. Verify TradierRateLimitError raised. Mock response with X-Ratelimit-Available: 0 header. Verify TradierRateLimitError raised.

  9. TestSessionCache: as in behavior — after two get_options_chain_structured("AAPL") calls, assert underlying mock call_count == 1; after clear_options_cache() and a third get_options_chain_structured("AAPL"), assert call_count == 2.

  10. TestSandboxURL: patch os.environ with TRADIER_SANDBOX=true. Assert get_base_url() returns "https://sandbox.tradier.com". Unset it. Assert returns "https://api.tradier.com".

Isolation pattern: every test class should clear the module cache in setUp and tearDown, e.g.:

class TestGetExpirations:
    def setup_method(self):
        from tradingagents.dataflows import tradier as t
        t.clear_options_cache()

    def teardown_method(self):
        from tradingagents.dataflows import tradier as t
        t.clear_options_cache()

    def test_expirations_filtered_by_dte(self):
        ...

Apply the same pattern to other classes touching get_options_chain / get_options_chain_structured. uv run python -m pytest tests/unit/data/test_tradier.py -x -v --timeout=30 <acceptance_criteria> - tests/conftest.py exists and contains MOCK_EXPIRATIONS_RESPONSE, MOCK_CHAIN_RESPONSE, MOCK_CHAIN_NO_GREEKS_RESPONSE, MOCK_SINGLE_CONTRACT_RESPONSE, MOCK_SINGLE_EXPIRATION_RESPONSE - tests/unit/data/test_tradier.py exists and defines at least 10 test classes (one per behavior bullet: TestGetExpirations, TestGetOptionsChain, …), each with one or more test_* methods - tests/unit/data/test_tradier.py contains class TestGetExpirations - tests/unit/data/test_tradier.py contains class TestGetOptionsChain - tests/unit/data/test_tradier.py contains class TestGreeksPresent - tests/unit/data/test_tradier.py contains class TestGreeksAbsent - tests/unit/data/test_tradier.py contains class TestIVPresent - tests/unit/data/test_tradier.py contains class TestDTEFilter - tests/unit/data/test_tradier.py contains class TestVendorRegistration - tests/unit/data/test_tradier.py contains class TestRateLimitDetection - tests/unit/data/test_tradier.py contains class TestSessionCache - tests/unit/data/test_tradier.py contains class TestSandboxURL - tests/unit/data/test_tradier.py contains clear_options_cache calls for test isolation - uv run python -m pytest tests/unit/data/test_tradier.py -x --timeout=30 exits 0 with all tests passing </acceptance_criteria> All tests pass. Tests cover: DATA-01 (chain retrieval), DATA-02 (expirations), DATA-03 (Greeks present), DATA-04 (IV present), DATA-05 (DTE filtering), DATA-08 (vendor registration), plus edge cases (no Greeks, single contract, single expiration, rate limits, caching, sandbox URL). No real API calls -- all mocked.

- `uv run python -m pytest tests/unit/data/test_tradier.py -x -v --timeout=30` -- all tests pass - `uv run python -c "from tradingagents.dataflows.interface import TOOLS_CATEGORIES, VENDOR_LIST; assert 'tradier' in VENDOR_LIST; assert 'options_chain' in TOOLS_CATEGORIES; print('ROUTING OK')"` -- vendor registered - `uv run python -c "from tradingagents.agents.utils.options_tools import get_options_chain, get_options_expirations; print('TOOLS OK')"` -- tool functions importable

<success_criteria>

  • Tradier fully registered in vendor routing (VENDOR_LIST, TOOLS_CATEGORIES, VENDOR_METHODS, DEFAULT_CONFIG)
  • route_to_vendor catches both AlphaVantageRateLimitError and TradierRateLimitError
  • Two @tool functions exist in options_tools.py following core_stock_tools.py pattern
  • .env.example documents TRADIER_API_KEY and TRADIER_SANDBOX
  • All unit tests pass covering DATA-01 through DATA-05 and DATA-08
  • No real API calls in tests (all mocked) </success_criteria>
After completion, create `.planning/phases/01-tradier-data-layer/01-02-SUMMARY.md`