diff --git a/src/marketdata/resources/options/expirations.py b/src/marketdata/resources/options/expirations.py index 88dc7c7..baab437 100644 --- a/src/marketdata/resources/options/expirations.py +++ b/src/marketdata/resources/options/expirations.py @@ -39,6 +39,11 @@ def expirations( self.client.default_params, user_universal_params ) + # Force dateformat=unix so the API returns unix timestamps instead of + # date-only strings (yyyy-mm-dd) which caused off-by-one-day errors + # when converted to datetime objects with timezone info. + user_universal_params.date_format = None + url = self._build_url( path=f"options/expirations/{symbol}/", user_universal_params=user_universal_params, @@ -46,6 +51,7 @@ def expirations( extra_params=kwargs, excluded_params=["symbol"], ) + url += "&dateformat=unix" self.logger.debug("Fetching options expirations...") response = self.client._make_request(method="GET", url=url) diff --git a/src/marketdata/utils.py b/src/marketdata/utils.py index 7c08f96..32a04ce 100644 --- a/src/marketdata/utils.py +++ b/src/marketdata/utils.py @@ -8,25 +8,27 @@ def format_timestamp(value: str | int | float | None) -> datetime.datetime: + default_tz = pytz.timezone("US/Eastern") + if isinstance(value, str): + if value.endswith("Z"): + value = value[:-1] + "+00:00" try: - return datetime.datetime.fromisoformat(value) - except: - pass - try: - value = float(value) - except: - raise ValueError("Unrecognized date format") + dt = datetime.datetime.fromisoformat(value) + return dt.astimezone(default_tz) if dt.tzinfo else dt + except ValueError: + try: + value = float(value) + except ValueError: + raise ValueError("Unrecognized date format") if isinstance(value, (int, float)): if 0 < value < 60000: return datetime.datetime(1899, 12, 30) + datetime.timedelta(days=value) try: - return datetime.datetime.fromtimestamp( - value, tz=pytz.timezone("US/Eastern") - ) - except: - raise ValueError("Unrecognized date format") + return datetime.datetime.fromtimestamp(value, tz=default_tz) + except (ValueError, OSError, OverflowError): + pass raise ValueError("Unrecognized date format") diff --git a/src/tests/data/options_expirations_human_response_200.json b/src/tests/data/options_expirations_human_response_200.json index ae5bb10..2481f8b 100644 --- a/src/tests/data/options_expirations_human_response_200.json +++ b/src/tests/data/options_expirations_human_response_200.json @@ -1,27 +1,27 @@ { "Expirations": [ - "2025-12-12", - "2025-12-19", - "2025-12-26", - "2026-01-02", - "2026-01-09", - "2026-01-16", - "2026-01-23", - "2026-01-30", - "2026-02-20", - "2026-03-20", - "2026-04-17", - "2026-05-15", - "2026-06-18", - "2026-07-17", - "2026-08-21", - "2026-09-18", - "2026-12-18", - "2027-01-15", - "2027-06-17", - "2027-12-17", - "2028-01-21", - "2028-03-17" + 1765515600, + 1766120400, + 1766725200, + 1767330000, + 1767934800, + 1768539600, + 1769144400, + 1769749200, + 1771563600, + 1773979200, + 1776398400, + 1778817600, + 1781755200, + 1784260800, + 1787284800, + 1789704000, + 1797570000, + 1799989200, + 1813204800, + 1829019600, + 1832043600, + 1836878400 ], "Date": 1765561297 } \ No newline at end of file diff --git a/src/tests/data/options_expirations_response_200.json b/src/tests/data/options_expirations_response_200.json index dce4a70..73a2596 100644 --- a/src/tests/data/options_expirations_response_200.json +++ b/src/tests/data/options_expirations_response_200.json @@ -1,28 +1,28 @@ { "s": "ok", "expirations": [ - "2025-12-05", - "2025-12-12", - "2025-12-19", - "2025-12-26", - "2026-01-02", - "2026-01-09", - "2026-01-16", - "2026-01-23", - "2026-02-20", - "2026-03-20", - "2026-04-17", - "2026-05-15", - "2026-06-18", - "2026-07-17", - "2026-08-21", - "2026-09-18", - "2026-12-18", - "2027-01-15", - "2027-06-17", - "2027-12-17", - "2028-01-21", - "2028-03-17" + 1764910800, + 1765515600, + 1766120400, + 1766725200, + 1767330000, + 1767934800, + 1768539600, + 1769144400, + 1771563600, + 1773979200, + 1776398400, + 1778817600, + 1781755200, + 1784260800, + 1787284800, + 1789704000, + 1797570000, + 1799989200, + 1813204800, + 1829019600, + 1832043600, + 1836878400 ], "updated": 1764941963 } \ No newline at end of file diff --git a/src/tests/test_options_expirations.py b/src/tests/test_options_expirations.py index a095f94..f0dc705 100644 --- a/src/tests/test_options_expirations.py +++ b/src/tests/test_options_expirations.py @@ -4,24 +4,28 @@ import pytz -from marketdata.input_types.base import OutputFormat +from marketdata.input_types.base import ( + OutputFormat, +) from marketdata.output_types.options_expirations import ( OptionsExpirations, OptionsExpirationsHumanReadable, ) from marketdata.sdk_error import MarketDataClientErrorResult +ET = pytz.timezone("US/Eastern") + def test_options_expirations_str(): timestamp = int( datetime.datetime( - 2025, 1, 1, 0, 0, 0, 0, pytz.timezone("US/Eastern") + 2025, 1, 1, 0, 0, 0, 0, ET ).timestamp() ) instance = OptionsExpirations( s="ok", - expirations=["2025-01-01"], + expirations=[timestamp], updated=timestamp, ) @@ -31,7 +35,7 @@ def test_options_expirations_str(): def test_options_expirations_human_readable_str(): timestamp = int( datetime.datetime( - 2025, 1, 1, 0, 0, 0, 0, pytz.timezone("US/Eastern") + 2025, 1, 1, 0, 0, 0, 0, ET ).timestamp() ) instance = OptionsExpirationsHumanReadable( @@ -54,13 +58,14 @@ def test_get_options_expirations_response_200_internal(load_json, respx_mock, cl ) assert expirations.s == "ok" assert len(expirations.expirations) == 22 - # Date strings are parsed as naive datetimes by format_timestamp - assert expirations.expirations[0] == datetime.datetime(2025, 12, 5, 0, 0) - # API returns UTC, convert to US/Eastern for comparison - expected = datetime.datetime( - 2025, 12, 5, 13, 39, 23, tzinfo=datetime.timezone.utc - ).astimezone(pytz.timezone("US/Eastern")) - assert expirations.updated.astimezone(pytz.timezone("US/Eastern")) == expected + # Unix timestamps are converted to US/Eastern datetimes + assert expirations.expirations[0] == datetime.datetime.fromtimestamp( + 1764910800, tz=ET + ) + assert expirations.expirations[0].date() == datetime.date(2025, 12, 5) + assert expirations.updated == datetime.datetime.fromtimestamp( + 1764941963, tz=ET + ) def test_get_options_expirations_response_200_json(load_json, respx_mock, client): @@ -86,13 +91,14 @@ def test_get_options_expirations_human_response_200(load_json, respx_mock, clien expirations = client.options.expirations( symbol="AAPL", output_format=OutputFormat.INTERNAL, use_human_readable=True ) - # Date strings are parsed as naive datetimes by format_timestamp - assert expirations.Expirations[0] == datetime.datetime(2025, 12, 12, 0, 0) - # API returns UTC, convert to US/Eastern for comparison - expected = datetime.datetime( - 2025, 12, 12, 17, 41, 37, tzinfo=datetime.timezone.utc - ).astimezone(pytz.timezone("US/Eastern")) - assert expirations.Date.astimezone(pytz.timezone("US/Eastern")) == expected + # Unix timestamps are converted to US/Eastern datetimes + assert expirations.Expirations[0] == datetime.datetime.fromtimestamp( + 1765515600, tz=ET + ) + assert expirations.Expirations[0].date() == datetime.date(2025, 12, 12) + assert expirations.Date == datetime.datetime.fromtimestamp( + 1765561297, tz=ET + ) def test_get_options_expirations_response_200_dataframe_pandas( @@ -117,7 +123,7 @@ def test_get_options_expirations_response_200_dataframe_pandas( assert "s" not in expirations.columns assert len(expirations) == 22 assert expirations["updated"].iloc[0] == datetime.datetime.fromtimestamp( - 1764941963, tz=pytz.timezone("US/Eastern") + 1764941963, tz=ET ) @@ -142,7 +148,7 @@ def test_get_options_expirations_response_200_dataframe_polars( assert "s" not in expirations.columns assert len(expirations) == 22 assert expirations["updated"][0] == datetime.datetime.fromtimestamp( - 1764941963, tz=pytz.timezone("US/Eastern") + 1764941963, tz=ET ) diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 586528c..54687c1 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -26,14 +26,36 @@ def test_format_timestamp(): assert format_timestamp(1714732800.0) == datetime.datetime.fromtimestamp( 1714732800, tz=pytz.timezone("US/Eastern") ) + # Test 'Z' suffix for Python < 3.11 compatibility + # Construct expected datetime using localize to avoid pytz LMT issues + expected_z = pytz.timezone("US/Eastern").localize( + datetime.datetime(2024, 1, 1, 7, 0, 0) + ) + assert format_timestamp("2024-01-01T12:00:00Z") == expected_z + with pytest.raises(ValueError): format_timestamp("2024-01-01 12:00:00.0:00:00") + # Coverage for line 21-23 (string that's not float) + with pytest.raises(ValueError): + format_timestamp("invalid-date") + # Test numeric exceptions (OSError/OverflowError) - coverage for line 30-31 with pytest.raises(ValueError): format_timestamp(99999999999999) + # Coverage for line 33 (final fallback) + with pytest.raises(ValueError): + # List is not str, int, float, or None + format_timestamp([]) with pytest.raises(ValueError): format_timestamp(None) +def test_format_timestamp_date_only_localization(): + val = "2026-02-20" + dt = format_timestamp(val) + assert dt == datetime.datetime(2026, 2, 20, 0, 0, 0) + assert dt.tzinfo is None + + def test_check_is_date(): assert check_is_date("2024-01-01") == True assert check_is_date(datetime.date(2024, 1, 1)) == True