450 lines
16 KiB
Python
450 lines
16 KiB
Python
"""Comprehensive unit tests for Tradier data layer (Phase 1 requirements).
|
|
|
|
Covers: 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).
|
|
|
|
All tests use mocked API responses -- no real Tradier calls are made.
|
|
"""
|
|
|
|
from datetime import date, timedelta
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
from conftest import (
|
|
_iso_days_out,
|
|
MOCK_EXPIRATIONS_RESPONSE,
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_NO_GREEKS_RESPONSE,
|
|
MOCK_SINGLE_CONTRACT_RESPONSE,
|
|
)
|
|
from tradingagents.dataflows.tradier import (
|
|
get_options_expirations,
|
|
get_options_chain,
|
|
get_options_chain_structured,
|
|
clear_options_cache,
|
|
OptionsChain,
|
|
OptionsContract,
|
|
)
|
|
from tradingagents.dataflows.tradier_common import (
|
|
TradierRateLimitError,
|
|
get_base_url,
|
|
make_tradier_request,
|
|
)
|
|
from tradingagents.dataflows.interface import (
|
|
TOOLS_CATEGORIES,
|
|
VENDOR_LIST,
|
|
VENDOR_METHODS,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGetExpirations (DATA-02)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetExpirations:
|
|
"""DATA-02: Options expirations retrieval with DTE filtering."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_expirations_filtered_by_dte(self, mock_request):
|
|
mock_request.return_value = MOCK_EXPIRATIONS_RESPONSE
|
|
result = get_options_expirations("AAPL", 0, 50)
|
|
assert isinstance(result, list)
|
|
today = date.today()
|
|
for d in result:
|
|
from datetime import datetime
|
|
exp = datetime.strptime(d, "%Y-%m-%d").date()
|
|
dte = (exp - today).days
|
|
assert 0 <= dte <= 50, f"Date {d} has DTE {dte}, outside 0-50 range"
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_expirations_returns_list_of_strings(self, mock_request):
|
|
mock_request.return_value = MOCK_EXPIRATIONS_RESPONSE
|
|
result = get_options_expirations("AAPL", 0, 50)
|
|
assert len(result) == 5
|
|
for d in result:
|
|
assert isinstance(d, str)
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_single_expiration_normalized(self, mock_request):
|
|
"""Pitfall 5: single-item response comes as string, not list."""
|
|
mock_request.return_value = MOCK_SINGLE_EXPIRATION_RESPONSE
|
|
result = get_options_expirations("AAPL", 0, 50)
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
assert result[0] == _iso_days_out(21)
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_expirations_narrow_dte_filter(self, mock_request):
|
|
mock_request.return_value = MOCK_EXPIRATIONS_RESPONSE
|
|
# Only dates between 10 and 25 DTE should pass
|
|
result = get_options_expirations("AAPL", 10, 25)
|
|
today = date.today()
|
|
for d in result:
|
|
from datetime import datetime
|
|
exp = datetime.strptime(d, "%Y-%m-%d").date()
|
|
dte = (exp - today).days
|
|
assert 10 <= dte <= 25
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGetOptionsChain (DATA-01)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetOptionsChain:
|
|
"""DATA-01: Options chain retrieval with correct structure."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_chain_structure(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_EXPIRATIONS_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
assert chain.underlying == "AAPL"
|
|
assert len(chain.contracts) > 0
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_chain_contract_values(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_EXPIRATIONS_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
c = chain.contracts[0]
|
|
assert c.bid == 5.10
|
|
assert c.volume == 1234
|
|
assert c.open_interest == 5678
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_single_contract_normalization(self, mock_request):
|
|
"""Pitfall 2: single contract comes as dict, not list."""
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_SINGLE_CONTRACT_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
assert len(chain.contracts) == 1
|
|
assert chain.contracts[0].symbol == "AAPL260417C00170000"
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_chain_string_format(self, mock_request):
|
|
"""get_options_chain returns string for LLM consumption."""
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
result = get_options_chain("AAPL")
|
|
assert isinstance(result, str)
|
|
assert "AAPL" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGreeksPresent (DATA-03)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGreeksPresent:
|
|
"""DATA-03: Greeks fields populated when present in API response."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_greeks_values(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
c = chain.contracts[0]
|
|
assert c.delta == 0.55
|
|
assert c.gamma == 0.04
|
|
assert c.theta == -0.08
|
|
assert c.vega == 0.25
|
|
assert c.rho == 0.03
|
|
assert c.greeks_updated_at == "2026-04-01 12:00:00"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestGreeksAbsent (Pitfall 1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGreeksAbsent:
|
|
"""Pitfall 1: Greeks null in response should not crash."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_no_greeks_yields_none(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_NO_GREEKS_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
c = chain.contracts[0]
|
|
assert c.delta is None
|
|
assert c.gamma is None
|
|
assert c.theta is None
|
|
assert c.vega is None
|
|
assert c.rho is None
|
|
assert c.greeks_updated_at is None
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_no_greeks_no_exception(self, mock_request):
|
|
"""Parsing should succeed without raising."""
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_NO_GREEKS_RESPONSE,
|
|
]
|
|
# Should not raise
|
|
chain = get_options_chain_structured("AAPL")
|
|
assert len(chain.contracts) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestIVPresent (DATA-04)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIVPresent:
|
|
"""DATA-04: IV fields populated when present in API response."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_iv_values(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
chain = get_options_chain_structured("AAPL")
|
|
c = chain.contracts[0]
|
|
assert c.bid_iv == 0.28
|
|
assert c.mid_iv == 0.29
|
|
assert c.ask_iv == 0.30
|
|
assert c.smv_vol == 0.285
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestDTEFilter (DATA-05)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDTEFilter:
|
|
"""DATA-05: DTE-based filtering on OptionsChain."""
|
|
|
|
def test_filter_by_dte_range(self):
|
|
today = date.today()
|
|
contracts = [
|
|
OptionsContract(
|
|
symbol=f"TEST{i}", underlying="TEST", option_type="call",
|
|
strike=100.0 + i * 5,
|
|
expiration_date=(today + timedelta(days=dte)).isoformat(),
|
|
bid=1.0, ask=1.5, last=1.25, volume=100, open_interest=500,
|
|
)
|
|
for i, dte in enumerate([5, 15, 25, 35, 55])
|
|
]
|
|
chain = OptionsChain(
|
|
underlying="TEST",
|
|
fetch_timestamp="2026-01-01T00:00:00",
|
|
expirations=sorted({c.expiration_date for c in contracts}),
|
|
contracts=contracts,
|
|
)
|
|
filtered = chain.filter_by_dte(10, 30)
|
|
# Only 15 and 25 DTE should remain
|
|
assert len(filtered.contracts) == 2
|
|
for c in filtered.contracts:
|
|
from datetime import datetime
|
|
exp = datetime.strptime(c.expiration_date, "%Y-%m-%d").date()
|
|
dte = (exp - today).days
|
|
assert 10 <= dte <= 30
|
|
|
|
def test_filter_updates_expirations_list(self):
|
|
today = date.today()
|
|
exp_10 = (today + timedelta(days=10)).isoformat()
|
|
exp_40 = (today + timedelta(days=40)).isoformat()
|
|
contracts = [
|
|
OptionsContract(
|
|
symbol="A", underlying="TEST", option_type="call",
|
|
strike=100.0, expiration_date=exp_10,
|
|
bid=1.0, ask=1.5, last=1.25, volume=100, open_interest=500,
|
|
),
|
|
OptionsContract(
|
|
symbol="B", underlying="TEST", option_type="call",
|
|
strike=100.0, expiration_date=exp_40,
|
|
bid=1.0, ask=1.5, last=1.25, volume=100, open_interest=500,
|
|
),
|
|
]
|
|
chain = OptionsChain(
|
|
underlying="TEST",
|
|
fetch_timestamp="2026-01-01T00:00:00",
|
|
expirations=[exp_10, exp_40],
|
|
contracts=contracts,
|
|
)
|
|
filtered = chain.filter_by_dte(5, 20)
|
|
assert exp_10 in filtered.expirations
|
|
assert exp_40 not in filtered.expirations
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestVendorRegistration (DATA-08)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVendorRegistration:
|
|
"""DATA-08: Tradier registered in vendor routing system."""
|
|
|
|
def test_tradier_in_vendor_list(self):
|
|
assert "tradier" in VENDOR_LIST
|
|
|
|
def test_options_chain_in_tools_categories(self):
|
|
assert "options_chain" in TOOLS_CATEGORIES
|
|
tools = TOOLS_CATEGORIES["options_chain"]["tools"]
|
|
assert "get_options_chain" in tools
|
|
assert "get_options_expirations" in tools
|
|
|
|
def test_get_options_chain_in_vendor_methods(self):
|
|
assert "get_options_chain" in VENDOR_METHODS
|
|
assert "tradier" in VENDOR_METHODS["get_options_chain"]
|
|
|
|
def test_get_options_expirations_in_vendor_methods(self):
|
|
assert "get_options_expirations" in VENDOR_METHODS
|
|
assert "tradier" in VENDOR_METHODS["get_options_expirations"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestRateLimitDetection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRateLimitDetection:
|
|
"""Rate limit detection raises TradierRateLimitError."""
|
|
|
|
@patch("tradingagents.dataflows.tradier_common.get_api_key", return_value="test-key")
|
|
@patch("tradingagents.dataflows.tradier_common.requests.get")
|
|
def test_http_429_raises(self, mock_get, mock_key):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 429
|
|
mock_response.headers = {}
|
|
mock_get.return_value = mock_response
|
|
with pytest.raises(TradierRateLimitError, match="429"):
|
|
make_tradier_request("/v1/markets/options/chains")
|
|
|
|
@patch("tradingagents.dataflows.tradier_common.get_api_key", return_value="test-key")
|
|
@patch("tradingagents.dataflows.tradier_common.requests.get")
|
|
def test_ratelimit_available_zero_raises(self, mock_get, mock_key):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {
|
|
"X-Ratelimit-Available": "0",
|
|
"X-Ratelimit-Expiry": "1700000000",
|
|
}
|
|
mock_get.return_value = mock_response
|
|
with pytest.raises(TradierRateLimitError, match="exhausted"):
|
|
make_tradier_request("/v1/markets/options/chains")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestSessionCache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSessionCache:
|
|
"""Session cache avoids redundant API calls."""
|
|
|
|
def setup_method(self):
|
|
clear_options_cache()
|
|
|
|
def teardown_method(self):
|
|
clear_options_cache()
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_cache_hit_avoids_second_call(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
# No more responses -- second call should hit cache
|
|
]
|
|
# First call
|
|
get_options_chain_structured("AAPL")
|
|
call_count_after_first = mock_request.call_count
|
|
|
|
# Second call (should be cache hit)
|
|
get_options_chain_structured("AAPL")
|
|
assert mock_request.call_count == call_count_after_first
|
|
|
|
@patch("tradingagents.dataflows.tradier.make_tradier_request_with_retry")
|
|
def test_cache_clear_forces_new_call(self, mock_request):
|
|
mock_request.side_effect = [
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
# After cache clear:
|
|
MOCK_SINGLE_EXPIRATION_RESPONSE,
|
|
MOCK_CHAIN_RESPONSE,
|
|
]
|
|
get_options_chain_structured("AAPL")
|
|
call_count_before_clear = mock_request.call_count
|
|
|
|
clear_options_cache()
|
|
get_options_chain_structured("AAPL")
|
|
assert mock_request.call_count > call_count_before_clear
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestSandboxURL
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSandboxURL:
|
|
"""Sandbox URL configuration via TRADIER_SANDBOX env var."""
|
|
|
|
@patch.dict("os.environ", {"TRADIER_SANDBOX": "true"})
|
|
def test_sandbox_true_returns_sandbox_url(self):
|
|
assert get_base_url() == "https://sandbox.tradier.com"
|
|
|
|
@patch.dict("os.environ", {"TRADIER_SANDBOX": "false"})
|
|
def test_sandbox_false_returns_production_url(self):
|
|
assert get_base_url() == "https://api.tradier.com"
|
|
|
|
@patch.dict("os.environ", {}, clear=True)
|
|
def test_unset_returns_production_url(self):
|
|
assert get_base_url() == "https://api.tradier.com"
|