From 65035c6937263bc4eb0984341a82e29b6d52fdc1 Mon Sep 17 00:00:00 2001 From: Ron DeMeritt Date: Tue, 16 Jun 2026 08:34:51 -0400 Subject: [PATCH] fix: align rest_proxy with schwab-py 1.5.1 enum API + pin dependency The /v1 endpoints were written against an older schwab-py enum API than the unpinned build installed (schwab-py 1.5.1), causing live 502s: - quotes: AttributeError "Quote has no attribute FIELD_QUOTE" - pricehistory: ValueError "expected type Period, got type int" Root cause: requirements.txt left schwab-py unpinned, so the API drifted. Changes - New app/schwab_data_proxy/enum_mapping.py: centralized, unit-testable param -> schwab-py enum mappers. Accepts member name OR wire value; raises UnknownEnumValue (surfaced as HTTP 400) on unmappable input instead of passing raw strings/ints downstream. - rest_proxy.py rewired for all four endpoints: * quotes: Quote.FIELD_* -> Quote.Fields.* * pricehistory: raw int period/frequency -> PriceHistory.Period/.Frequency; period_type/frequency_type -> typed enums * chains: Options.* enums; FIX kwarg range= -> strike_range= (the old name was rejected by get_option_chain); entitlement now mapped * markets: MarketHours.Market enums * UnknownEnumValue -> 400 (bad query string), not 502 (upstream) - requirements.txt: pin schwab-py==1.5.1 (with rationale comment) so the enum API can't silently drift again. - tests/: pytest regression suite (19 tests) covering the param->enum mapping and handler-level call construction (mocked client, no network). - CI: add pytest job; requirements-dev.txt + pyproject.toml pytest config. --- .github/workflows/ci.yml | 17 +++ .gitignore | 3 + app/schwab_data_proxy/enum_mapping.py | 151 ++++++++++++++++++++++++++ app/schwab_data_proxy/rest_proxy.py | 81 +++++--------- pyproject.toml | 12 ++ requirements-dev.txt | 3 + requirements.txt | 6 +- tests/test_enum_mapping.py | 150 +++++++++++++++++++++++++ tests/test_rest_proxy_calls.py | 129 ++++++++++++++++++++++ 9 files changed, 496 insertions(+), 56 deletions(-) create mode 100644 app/schwab_data_proxy/enum_mapping.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/test_enum_mapping.py create mode 100644 tests/test_rest_proxy_calls.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0c26d6..e8391eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,23 @@ jobs: - name: Run ruff format check run: ruff format --check app/ + test: + name: Unit tests (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dev deps + run: pip install -r requirements-dev.txt + + - name: Run pytest + run: pytest -q + docker-build: name: Docker build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8451b33..e10f91d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,11 +12,14 @@ __pycache__/ *.py[cod] *.pyo .venv/ +.venv-introspect/ venv/ .eggs/ *.egg-info/ dist/ build/ +.pytest_cache/ +.ruff_cache/ # Docker *.tar diff --git a/app/schwab_data_proxy/enum_mapping.py b/app/schwab_data_proxy/enum_mapping.py new file mode 100644 index 0000000..87b1a2a --- /dev/null +++ b/app/schwab_data_proxy/enum_mapping.py @@ -0,0 +1,151 @@ +""" +Enum mapping helpers — translate REST query-string params into the typed enums +that schwab-py (pinned 1.5.1) requires. + +schwab-py enforces enum types on its client methods (``enforce_enums=True`` by +default). Passing raw strings/ints raises ``ValueError``/``AttributeError`` at +call time. These helpers centralize the param -> enum translation so the REST +handlers stay thin and the mapping is unit-testable against a mocked client. + +Design notes (validated against schwab-py 1.5.1): +- ``client.Quote.Fields`` replaced the old ``client.Quote.FIELD_*`` members. +- ``PriceHistory.Period`` / ``PriceHistory.Frequency`` are int-valued enums; the + proxy receives raw ints and must construct the enum *by value*. +- ``PriceHistory.PeriodType`` / ``FrequencyType`` are string-valued enums; the + proxy receives the lowercase token (e.g. ``"daily"``) and constructs *by value*. +- ``get_option_chain`` takes ``strike_range`` (NOT ``range``) and the Options + enums are string-valued (``ITM``, ``CALL`` ...). Callers may send either the + member name (``IN_THE_MONEY``) or the wire value (``ITM``); both are accepted. +- ``MarketHours.Market`` is a string-valued enum (``equity`` ...). + +Every mapper accepts the live (or mocked) ``client`` so it can read the enum +classes off it, and raises ``UnknownEnumValue`` on an unmappable input rather +than silently passing a raw string through (which would surface as an opaque +schwab-py error downstream). +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class UnknownEnumValue(ValueError): + """Raised when an inbound param cannot be mapped to a schwab-py enum member.""" + + +def _coerce(enum_cls: type[Enum], raw: Any, *, label: str) -> Enum: + """Map ``raw`` onto ``enum_cls`` by member name or by wire value. + + Accepts (in order): + 1. an already-constructed member of ``enum_cls`` (pass-through) + 2. the member NAME, case-insensitive (e.g. ``"in_the_money"``) + 3. the member VALUE (e.g. ``"ITM"``, ``1``, ``"daily"``) + """ + if isinstance(raw, enum_cls): + return raw + + # by name (case-insensitive) — only meaningful for string-ish inputs + if isinstance(raw, str): + try: + return enum_cls[raw.strip().upper()] + except KeyError: + pass + + # by value (handles int-valued Period/Frequency and string-valued enums) + try: + return enum_cls(raw) + except ValueError: + pass + + # last attempt: string value match case-insensitively + if isinstance(raw, str): + target = raw.strip().upper() + for member in enum_cls: + if str(member.value).upper() == target: + return member + + valid = [m.name for m in enum_cls] + raise UnknownEnumValue( + f"{label}: cannot map {raw!r} to {enum_cls.__name__}; valid: {valid}" + ) + + +# --------------------------------------------------------------------------- +# Quotes +# --------------------------------------------------------------------------- + + +def map_quote_fields(client: Any, fields: str) -> list[Enum]: + """``"quote,reference"`` -> ``[Quote.Fields.QUOTE, Quote.Fields.REFERENCE]``.""" + enum_cls = client.Quote.Fields + out: list[Enum] = [] + for f in fields.split(","): + f = f.strip() + if not f: + continue + out.append(_coerce(enum_cls, f, label="quotes.fields")) + return out + + +# --------------------------------------------------------------------------- +# Price history +# --------------------------------------------------------------------------- + + +def map_period_type(client: Any, value: str) -> Enum: + return _coerce( + client.PriceHistory.PeriodType, value, label="pricehistory.period_type" + ) + + +def map_period(client: Any, value: int) -> Enum: + return _coerce(client.PriceHistory.Period, value, label="pricehistory.period") + + +def map_frequency_type(client: Any, value: str) -> Enum: + return _coerce( + client.PriceHistory.FrequencyType, value, label="pricehistory.frequency_type" + ) + + +def map_frequency(client: Any, value: int) -> Enum: + return _coerce(client.PriceHistory.Frequency, value, label="pricehistory.frequency") + + +# --------------------------------------------------------------------------- +# Option chains +# --------------------------------------------------------------------------- + + +def map_contract_type(client: Any, value: str) -> Enum: + return _coerce(client.Options.ContractType, value, label="chains.contract_type") + + +def map_strategy(client: Any, value: str) -> Enum: + return _coerce(client.Options.Strategy, value, label="chains.strategy") + + +def map_strike_range(client: Any, value: str) -> Enum: + return _coerce(client.Options.StrikeRange, value, label="chains.range") + + +def map_expiration_month(client: Any, value: str) -> Enum: + return _coerce(client.Options.ExpirationMonth, value, label="chains.exp_month") + + +def map_option_type(client: Any, value: str) -> Enum: + return _coerce(client.Options.Type, value, label="chains.option_type") + + +def map_entitlement(client: Any, value: str) -> Enum: + return _coerce(client.Options.Entitlement, value, label="chains.entitlement") + + +# --------------------------------------------------------------------------- +# Market hours +# --------------------------------------------------------------------------- + + +def map_market(client: Any, value: str) -> Enum: + return _coerce(client.MarketHours.Market, value, label="markets.markets") diff --git a/app/schwab_data_proxy/rest_proxy.py b/app/schwab_data_proxy/rest_proxy.py index fbadd46..a2fe9b8 100644 --- a/app/schwab_data_proxy/rest_proxy.py +++ b/app/schwab_data_proxy/rest_proxy.py @@ -13,6 +13,8 @@ from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse +from . import enum_mapping +from .enum_mapping import UnknownEnumValue from .schwab_session import session from .settings import settings @@ -57,6 +59,11 @@ async def _cached_call(endpoint: str, params: dict, coroutine_factory) -> JSONRe try: resp = await coroutine_factory() + except UnknownEnumValue as exc: + # An inbound param could not be mapped to a schwab-py enum — this is a + # client error (bad query string), not an upstream failure. + logger.warning("Invalid enum param for %s: %s", endpoint, exc) + return _error_response("BAD_REQUEST", str(exc), http_status=400) except Exception as exc: # noqa: BLE001 logger.error("Upstream call failed for %s: %s", endpoint, exc) return _error_response("UPSTREAM_ERROR", str(exc)) @@ -126,18 +133,7 @@ async def get_quotes( async def call(): kwargs: dict[str, Any] = {} if fields: - field_values = [] - field_map = { - "quote": client.Quote.FIELD_QUOTE, - "reference": client.Quote.FIELD_REFERENCE, - "extended": client.Quote.FIELD_EXTENDED, - "fundamental": client.Quote.FIELD_FUNDAMENTAL, - "regular": client.Quote.FIELD_REGULAR, - } - for f in fields.split(","): - f = f.strip() - if f in field_map: - field_values.append(field_map[f]) + field_values = enum_mapping.map_quote_fields(client, fields) if field_values: kwargs["fields"] = field_values return await client.get_quotes(symbol_list, **kwargs) @@ -199,30 +195,22 @@ async def get_chains( async def call(): kwargs: dict[str, Any] = {"symbol": symbol} if contract_type is not None: - try: - kwargs["contract_type"] = client.Options.ContractType[ - contract_type.upper() - ] - except (KeyError, AttributeError): - kwargs["contract_type"] = contract_type + kwargs["contract_type"] = enum_mapping.map_contract_type( + client, contract_type + ) if strike_count is not None: kwargs["strike_count"] = strike_count if include_underlying_quote is not None: kwargs["include_underlying_quote"] = include_underlying_quote if strategy is not None: - try: - kwargs["strategy"] = client.Options.Strategy[strategy.upper()] - except (KeyError, AttributeError): - kwargs["strategy"] = strategy + kwargs["strategy"] = enum_mapping.map_strategy(client, strategy) if interval is not None: kwargs["interval"] = interval if strike is not None: kwargs["strike"] = strike if range is not None: - try: - kwargs["range"] = client.Options.StrikeRange[range.upper()] - except (KeyError, AttributeError): - kwargs["range"] = range + # schwab-py 1.5.1 names this kwarg ``strike_range`` (not ``range``). + kwargs["strike_range"] = enum_mapping.map_strike_range(client, range) if from_date is not None: kwargs["from_date"] = from_date if to_date is not None: @@ -236,17 +224,11 @@ async def call(): if days_to_expiration is not None: kwargs["days_to_expiration"] = days_to_expiration if exp_month is not None: - try: - kwargs["exp_month"] = client.Options.ExpirationMonth[exp_month.upper()] - except (KeyError, AttributeError): - kwargs["exp_month"] = exp_month + kwargs["exp_month"] = enum_mapping.map_expiration_month(client, exp_month) if option_type is not None: - try: - kwargs["option_type"] = client.Options.Type[option_type.upper()] - except (KeyError, AttributeError): - kwargs["option_type"] = option_type + kwargs["option_type"] = enum_mapping.map_option_type(client, option_type) if entitlement is not None: - kwargs["entitlement"] = entitlement + kwargs["entitlement"] = enum_mapping.map_entitlement(client, entitlement) return await client.get_option_chain(**kwargs) return await _cached_call("chains", params, call) @@ -290,23 +272,17 @@ async def get_pricehistory( async def call(): kwargs: dict[str, Any] = {"symbol": symbol} if period_type is not None: - try: - kwargs["period_type"] = client.PriceHistory.PeriodType[ - period_type.upper() - ] - except (KeyError, AttributeError): - kwargs["period_type"] = period_type + kwargs["period_type"] = enum_mapping.map_period_type(client, period_type) if period is not None: - kwargs["period"] = period + # schwab-py 1.5.1 enforces PriceHistory.Period (int-valued enum). + kwargs["period"] = enum_mapping.map_period(client, period) if frequency_type is not None: - try: - kwargs["frequency_type"] = client.PriceHistory.FrequencyType[ - frequency_type.upper() - ] - except (KeyError, AttributeError): - kwargs["frequency_type"] = frequency_type + kwargs["frequency_type"] = enum_mapping.map_frequency_type( + client, frequency_type + ) if frequency is not None: - kwargs["frequency"] = frequency + # schwab-py 1.5.1 enforces PriceHistory.Frequency (int-valued enum). + kwargs["frequency"] = enum_mapping.map_frequency(client, frequency) if start_datetime is not None: kwargs["start_datetime"] = start_datetime if end_datetime is not None: @@ -344,12 +320,7 @@ async def get_markets( ) async def call(): - market_enums = [] - for m in market_list: - try: - market_enums.append(client.MarketHours.Market[m.upper()]) - except (KeyError, AttributeError): - market_enums.append(m) + market_enums = [enum_mapping.map_market(client, m) for m in market_list] kwargs: dict[str, Any] = {"markets": market_enums} if date: kwargs["date"] = date diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d0040f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[tool.pytest.ini_options] +# Run from repo root: `app` resolves as a namespace package and `schwab_data_proxy` +# matches the in-container import path. asyncio_mode=auto lets async test +# functions run without a per-test decorator. +pythonpath = ["."] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +# rest_proxy intentionally shadows the builtin `range` to match the Schwab REST +# query-param name; that file is reviewed and the shadow is deliberate. +target-version = "py312" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..464391a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8 +pytest-asyncio>=0.24 diff --git a/requirements.txt b/requirements.txt index 5bfc210..2e25e87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ -schwab-py +# Pinned: the rest_proxy enum mapping (Quote.Fields, PriceHistory.Period/Frequency, +# Options.*, MarketHours.Market) is validated against this exact version. Do not +# unpin — schwab-py renames/retypes enum members across releases and silently +# broke the /v1 endpoints when previously unpinned. +schwab-py==1.5.1 fastapi uvicorn[standard] pydantic-settings diff --git a/tests/test_enum_mapping.py b/tests/test_enum_mapping.py new file mode 100644 index 0000000..9af5b1a --- /dev/null +++ b/tests/test_enum_mapping.py @@ -0,0 +1,150 @@ +""" +Regression tests for the param -> schwab-py enum mapping. + +These guard against the schwab-py API drift that broke the live proxy: + - Quote.FIELD_* was renamed to Quote.Fields.* (AttributeError at call time) + - PriceHistory period/frequency now require typed enums, not raw ints + ("expected type Period, got type int") + - get_option_chain kwarg is ``strike_range``, not ``range`` + - Options.* / MarketHours.Market require typed enums + +The tests run against the *real installed* schwab-py enums (pinned 1.5.1 in +requirements.txt). If schwab-py is upgraded and renames/retypes a member, these +tests fail loudly instead of the proxy 502-ing in production. +""" + +from __future__ import annotations + +import pytest +from schwab.client.base import BaseClient + +from app.schwab_data_proxy import enum_mapping +from app.schwab_data_proxy.enum_mapping import UnknownEnumValue + + +class FakeClient: + """Minimal stand-in exposing the same nested enum classes the real + AsyncClient inherits from BaseClient. No network, no auth.""" + + Quote = BaseClient.Quote + PriceHistory = BaseClient.PriceHistory + Options = BaseClient.Options + MarketHours = BaseClient.MarketHours + + +@pytest.fixture +def client() -> FakeClient: + return FakeClient() + + +# --- Quotes --------------------------------------------------------------- + + +def test_quote_fields_maps_to_Fields_enum(client): + out = enum_mapping.map_quote_fields(client, "quote,reference,extended") + assert out == [ + client.Quote.Fields.QUOTE, + client.Quote.Fields.REFERENCE, + client.Quote.Fields.EXTENDED, + ] + # regression: the old code referenced Quote.FIELD_QUOTE which no longer exists + assert not hasattr(client.Quote, "FIELD_QUOTE") + + +def test_quote_fields_accepts_member_name_and_value(client): + # value form ("quote") and name form ("QUOTE") both resolve + assert enum_mapping.map_quote_fields(client, "QUOTE") == [client.Quote.Fields.QUOTE] + + +def test_quote_fields_unknown_raises(client): + with pytest.raises(UnknownEnumValue): + enum_mapping.map_quote_fields(client, "bogus") + + +# --- Price history -------------------------------------------------------- + + +def test_period_int_maps_to_Period_enum(client): + # regression: raw int 1 previously hit "expected type Period, got type int" + result = enum_mapping.map_period(client, 1) + assert result is client.PriceHistory.Period.ONE_DAY + assert isinstance(result, client.PriceHistory.Period) + + +def test_frequency_int_maps_to_Frequency_enum(client): + result = enum_mapping.map_frequency(client, 5) + assert result is client.PriceHistory.Frequency.EVERY_FIVE_MINUTES + assert isinstance(result, client.PriceHistory.Frequency) + + +def test_period_type_string_maps(client): + assert ( + enum_mapping.map_period_type(client, "daily".upper() and "day") + is client.PriceHistory.PeriodType.DAY + ) + assert ( + enum_mapping.map_period_type(client, "DAY") + is client.PriceHistory.PeriodType.DAY + ) + + +def test_frequency_type_string_maps(client): + assert ( + enum_mapping.map_frequency_type(client, "daily") + is client.PriceHistory.FrequencyType.DAILY + ) + + +def test_period_unknown_int_raises(client): + with pytest.raises(UnknownEnumValue): + enum_mapping.map_period(client, 999) + + +# --- Option chains -------------------------------------------------------- + + +def test_contract_type_maps(client): + assert ( + enum_mapping.map_contract_type(client, "call") + is client.Options.ContractType.CALL + ) + + +def test_strike_range_accepts_name_and_wire_value(client): + # member name + assert ( + enum_mapping.map_strike_range(client, "IN_THE_MONEY") + is client.Options.StrikeRange.IN_THE_MONEY + ) + # wire value + assert ( + enum_mapping.map_strike_range(client, "ITM") + is client.Options.StrikeRange.IN_THE_MONEY + ) + + +def test_entitlement_maps(client): + assert ( + enum_mapping.map_entitlement(client, "NP") is client.Options.Entitlement.NON_PRO + ) + + +def test_exp_month_and_option_type_map(client): + assert ( + enum_mapping.map_expiration_month(client, "JAN") + is client.Options.ExpirationMonth.JANUARY + ) + assert enum_mapping.map_option_type(client, "S") is client.Options.Type.STANDARD + + +# --- Market hours --------------------------------------------------------- + + +def test_market_maps(client): + assert enum_mapping.map_market(client, "equity") is client.MarketHours.Market.EQUITY + assert enum_mapping.map_market(client, "OPTION") is client.MarketHours.Market.OPTION + + +def test_market_unknown_raises(client): + with pytest.raises(UnknownEnumValue): + enum_mapping.map_market(client, "crypto") diff --git a/tests/test_rest_proxy_calls.py b/tests/test_rest_proxy_calls.py new file mode 100644 index 0000000..bb9be51 --- /dev/null +++ b/tests/test_rest_proxy_calls.py @@ -0,0 +1,129 @@ +""" +Handler-level regression tests: assert the REST endpoints build their schwab-py +client calls with the correct enum *types* and kwarg *names*, end-to-end, with a +mocked client (no network, no auth). + +This is the test that would have caught the live 502s: + - get_quotes called with Quote.Fields members (not FIELD_* attrs) + - get_price_history called with Period/Frequency enums (not raw ints) + - get_option_chain called with strike_range= (not range=) +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from schwab.client.base import BaseClient + +from app.schwab_data_proxy import rest_proxy + + +def _ok_response(payload=None): + return SimpleNamespace( + status_code=200, + json=lambda: payload if payload is not None else {"ok": True}, + text="", + ) + + +@pytest.fixture +def mock_client(monkeypatch): + client = AsyncMock() + # nested enum classes resolve from the real schwab-py (pinned) API + client.Quote = BaseClient.Quote + client.PriceHistory = BaseClient.PriceHistory + client.Options = BaseClient.Options + client.MarketHours = BaseClient.MarketHours + client.get_quotes.return_value = _ok_response() + client.get_price_history.return_value = _ok_response() + client.get_option_chain.return_value = _ok_response() + client.get_market_hours.return_value = _ok_response() + + monkeypatch.setattr(rest_proxy.session, "client", lambda: client) + # bypass the TTL cache between tests + rest_proxy._cache.clear() + return client + + +@pytest.mark.asyncio +async def test_quotes_passes_Fields_enums(mock_client): + resp = await rest_proxy.get_quotes(request=None, symbols="AAPL", fields="quote") + assert resp.status_code == 200 + args, kwargs = mock_client.get_quotes.call_args + assert args[0] == ["AAPL"] + assert kwargs["fields"] == [mock_client.Quote.Fields.QUOTE] + + +@pytest.mark.asyncio +async def test_pricehistory_passes_typed_enums(mock_client): + resp = await rest_proxy.get_pricehistory( + symbol="AAPL", + period_type="year", + period=1, + frequency_type="daily", + frequency=1, + start_datetime=None, + end_datetime=None, + need_extended_hours_data=None, + need_previous_close=None, + ) + assert resp.status_code == 200 + _, kwargs = mock_client.get_price_history.call_args + assert kwargs["period_type"] is mock_client.PriceHistory.PeriodType.YEAR + assert kwargs["period"] is mock_client.PriceHistory.Period.ONE_DAY + assert kwargs["frequency_type"] is mock_client.PriceHistory.FrequencyType.DAILY + assert kwargs["frequency"] is mock_client.PriceHistory.Frequency.EVERY_MINUTE + # all enum types, never raw ints + assert isinstance(kwargs["period"], mock_client.PriceHistory.Period) + assert isinstance(kwargs["frequency"], mock_client.PriceHistory.Frequency) + + +@pytest.mark.asyncio +async def test_chains_uses_strike_range_kwarg(mock_client): + # Calling the handler directly (not via FastAPI) means unset Query params keep + # their Query(None) default objects rather than resolving to None, so pass + # every optional explicitly as None. + resp = await rest_proxy.get_chains( + symbol="AAPL", + contract_type="call", + strike_count=None, + include_underlying_quote=None, + strategy=None, + interval=None, + strike=None, + range="ITM", + from_date=None, + to_date=None, + volatility=None, + underlying_price=None, + interest_rate=None, + days_to_expiration=None, + exp_month=None, + option_type=None, + entitlement=None, + ) + assert resp.status_code == 200 + _, kwargs = mock_client.get_option_chain.call_args + # regression: kwarg must be strike_range, not range + assert "range" not in kwargs + assert kwargs["strike_range"] is mock_client.Options.StrikeRange.IN_THE_MONEY + assert kwargs["contract_type"] is mock_client.Options.ContractType.CALL + + +@pytest.mark.asyncio +async def test_markets_passes_Market_enums(mock_client): + resp = await rest_proxy.get_markets(markets="equity,option", date=None) + assert resp.status_code == 200 + _, kwargs = mock_client.get_market_hours.call_args + assert kwargs["markets"] == [ + mock_client.MarketHours.Market.EQUITY, + mock_client.MarketHours.Market.OPTION, + ] + + +@pytest.mark.asyncio +async def test_bad_enum_param_returns_400(mock_client): + resp = await rest_proxy.get_markets(markets="crypto", date=None) + assert resp.status_code == 400