diff --git a/src/marketdata/exceptions.py b/src/marketdata/exceptions.py index 40fa336..c2225ec 100644 --- a/src/marketdata/exceptions.py +++ b/src/marketdata/exceptions.py @@ -28,9 +28,9 @@ from datetime import datetime from httpx import Request, Response -from pytz import timezone from marketdata.internal_settings import ( + _DEFAULT_TIMEZONE, HEADER_AUTHORIZED_IP, HEADER_REQUEST_ID, read_header, @@ -78,8 +78,17 @@ def format_timestamp(cls, timestamp: datetime) -> str: @classmethod def _coerce_timestamp(cls, timestamp: datetime | str | None) -> str: + """The exception's timestamp as text. + + Args: + timestamp: A datetime, rendered by ``format_timestamp``; text, kept + as it is; or ``None`` for the current time in US/Eastern. + + Returns: + The timestamp as ``YYYY-MM-DD HH:MM:SS``, or the text given. + """ if timestamp is None: - return cls.format_timestamp(datetime.now(timezone("US/Eastern"))) + return cls.format_timestamp(datetime.now(_DEFAULT_TIMEZONE)) if isinstance(timestamp, datetime): return cls.format_timestamp(timestamp) return timestamp diff --git a/src/marketdata/internal_settings.py b/src/marketdata/internal_settings.py index bb76f6b..7fc3945 100644 --- a/src/marketdata/internal_settings.py +++ b/src/marketdata/internal_settings.py @@ -1,6 +1,7 @@ import datetime import httpx +import pytz from httpx import Response @@ -32,6 +33,9 @@ class NoTokenValueType: # than that is a value the SDK could not read rather than a window that closed # (#42). MAX_CREDIT_WINDOW_SECONDS = 24 * 60 * 60 +# The zone of every timestamp the SDK renders. It is kept here, not in utils, +# because utils imports exceptions, which needs it too. +_DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") # The API's IP headers (#44). httpx matches header names case-insensitively, # so the lowercase spelling reads them whatever case the API sends. The legacy diff --git a/src/marketdata/output_handlers/pandas.py b/src/marketdata/output_handlers/pandas.py index 4409c42..d8cd8c7 100644 --- a/src/marketdata/output_handlers/pandas.py +++ b/src/marketdata/output_handlers/pandas.py @@ -1,8 +1,8 @@ import numpy as np import pandas as pd -import pytz from marketdata.input_types.base import DateFormat +from marketdata.internal_settings import _DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: "float64", int: "int64", bool: "bool", str: "object"} @@ -50,12 +50,22 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pd.DataFrame: - """Convert date/time columns to timezone-aware datetime objects.""" + """Convert the date columns to US/Eastern datetimes. + + Args: + df: The result, with its columns cast. + date_columns: The columns to convert. + date_format: The request's date format. ``None`` reads the values + as Unix seconds, and ``DateFormat.UNIX`` leaves them as numbers. + + Returns: + The same frame. A column that cannot be converted keeps its values. + """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = pytz.timezone("US/Eastern") + default_tz = _DEFAULT_TIMEZONE for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/output_handlers/polars.py b/src/marketdata/output_handlers/polars.py index 5fb466e..5778eaf 100644 --- a/src/marketdata/output_handlers/polars.py +++ b/src/marketdata/output_handlers/polars.py @@ -1,7 +1,7 @@ import polars as pl -import pytz from marketdata.input_types.base import DateFormat +from marketdata.internal_settings import _DEFAULT_TIMEZONE from marketdata.output_handlers.base import BaseOutputHandler _DTYPES = {float: pl.Float64, int: pl.Int64, bool: pl.Boolean, str: pl.String} @@ -40,12 +40,23 @@ def _convert_timestamp_columns( date_columns: list[str], date_format: DateFormat | None, ) -> pl.DataFrame: - """Convert date/time columns to timezone-aware datetime objects.""" + """Convert the date columns to US/Eastern datetimes. + + Args: + df: The result, with its columns cast. + date_columns: The columns to convert. + date_format: The request's date format. ``None`` reads the values + as Unix seconds, and ``DateFormat.UNIX`` leaves them as numbers. + + Returns: + The frame with its date columns converted. A column that cannot be + converted keeps its values. + """ if date_format == DateFormat.UNIX: return df format_to_use = date_format or DateFormat.UNIX - default_tz = pytz.timezone("US/Eastern").zone + default_tz = _DEFAULT_TIMEZONE.zone for col in df.columns: if col not in date_columns: diff --git a/src/marketdata/types.py b/src/marketdata/types.py index b2565ea..74ea8fd 100644 --- a/src/marketdata/types.py +++ b/src/marketdata/types.py @@ -3,7 +3,7 @@ from pytz.exceptions import InvalidTimeError -from marketdata.utils import DEFAULT_TIMEZONE +from marketdata.internal_settings import _DEFAULT_TIMEZONE @dataclass @@ -36,7 +36,7 @@ def __post_init__(self): raise refused try: reset_time = datetime.datetime.fromtimestamp( - float(reset_time), tz=DEFAULT_TIMEZONE + float(reset_time), tz=_DEFAULT_TIMEZONE ) except (TypeError, ValueError, OverflowError, OSError): raise refused from None @@ -44,7 +44,7 @@ def __post_init__(self): try: # `is_dst=None`: a repeated or skipped wall time names no single # instant, and picking one would move the reset by an hour. - reset_time = DEFAULT_TIMEZONE.localize(reset_time, is_dst=None) + reset_time = _DEFAULT_TIMEZONE.localize(reset_time, is_dst=None) except InvalidTimeError as exc: raise ValueError( f"{reset_time.isoformat()} is not one US/Eastern time: a " diff --git a/src/marketdata/utils.py b/src/marketdata/utils.py index 180f209..151441f 100644 --- a/src/marketdata/utils.py +++ b/src/marketdata/utils.py @@ -7,16 +7,13 @@ from typing import Any, Iterator, NoReturn from urllib.parse import quote -import pytz from httpx import Response from marketdata.exceptions import ParseError -from marketdata.internal_settings import VALID_STATUS_CODES +from marketdata.internal_settings import _DEFAULT_TIMEZONE, VALID_STATUS_CODES -# Every timestamp the SDK renders is US/Eastern. One constant, so the zone a -# naive value is read in and the zone it is rendered in agree by construction -# rather than because two literals happen to match. -DEFAULT_TIMEZONE = pytz.timezone("US/Eastern") +# Assigned, not imported: an imported name is not part of the public surface. +DEFAULT_TIMEZONE = _DEFAULT_TIMEZONE def _exact_number(text: str) -> Decimal: diff --git a/src/tests/test_default_timezone.py b/src/tests/test_default_timezone.py new file mode 100644 index 0000000..ec83775 --- /dev/null +++ b/src/tests/test_default_timezone.py @@ -0,0 +1,111 @@ +"""Every timestamp the SDK renders takes its zone from one constant.""" + +import datetime +import time +from unittest.mock import patch + +import pytest +import pytz + +from marketdata import exceptions, get_meta, types, utils +from marketdata.exceptions import BadRequestError +from marketdata.input_types.base import OutputFormat +from marketdata.output_handlers import pandas as pandas_handler +from marketdata.output_handlers import polars as polars_handler +from src.tests.conftest import use_real_header_extraction + +QUOTES_URL = "https://api.marketdata.app/v1/stocks/quotes/" +UPDATED = 1765552906 +TOKYO = pytz.timezone("Asia/Tokyo") +STAMP = "%Y-%m-%d %H:%M:%S" + + +@pytest.fixture +def tokyo(monkeypatch): + """Point every module that renders a timestamp at Asia/Tokyo, a zone with + no daylight saving time and 13 or 14 hours away from US/Eastern. + + Returns: + The Asia/Tokyo zone. + """ + for module, name in ( + (utils, "DEFAULT_TIMEZONE"), + (types, "_DEFAULT_TIMEZONE"), + (exceptions, "_DEFAULT_TIMEZONE"), + (pandas_handler, "_DEFAULT_TIMEZONE"), + (polars_handler, "_DEFAULT_TIMEZONE"), + ): + monkeypatch.setattr(module, name, TOKYO) + return TOKYO + + +def test_utils_default_timezone_is_us_eastern(): + """`marketdata.utils.DEFAULT_TIMEZONE` is the US/Eastern zone.""" + assert utils.DEFAULT_TIMEZONE is pytz.timezone("US/Eastern") + + +def test_internal_dates_and_the_credit_reset_follow_the_constant( + tokyo, load_json, respx_mock, client +): + """An INTERNAL result's dates and the credit reset read from its headers + are in the zone the constant holds.""" + use_real_header_extraction(client) + reset = int(time.time()) + 60 + respx_mock.get(QUOTES_URL).respond( + json=load_json("stocks_quotes_response_200"), + headers={ + "x-api-ratelimit-limit": "100", + "x-api-ratelimit-remaining": "99", + "x-api-ratelimit-reset": str(reset), + "x-api-ratelimit-consumed": "1", + }, + status_code=200, + ) + + quotes = client.stocks.quotes( + symbols=["AAPL", "MSFT"], output_format=OutputFormat.INTERNAL + ) + reset_time = get_meta(quotes).rate_limits.reset_time + + assert quotes[0].updated == datetime.datetime.fromtimestamp(UPDATED, tz=tokyo) + assert str(quotes[0].updated.tzinfo) == "Asia/Tokyo" + assert reset_time.timestamp() == reset + assert str(reset_time.tzinfo) == "Asia/Tokyo" + + +@pytest.mark.parametrize("library", ["pandas", "polars"]) +def test_dataframe_dates_follow_the_constant( + tokyo, load_json, respx_mock, client, library +): + """A DataFrame's date column is in the zone the constant holds. + + Args: + library: The DataFrame library the client builds the result with. + """ + respx_mock.get(QUOTES_URL).respond( + json=load_json("stocks_quotes_response_200"), status_code=200 + ) + + with patch("marketdata.output_handlers.DATAFRAME_HANDLERS_PRIORITY", [library]): + frame = client.stocks.quotes( + symbols=["AAPL", "MSFT"], output_format=OutputFormat.DATAFRAME + ) + updated = frame["updated"].to_list()[0] + + assert updated == datetime.datetime.fromtimestamp(UPDATED, tz=tokyo) + assert str(updated.tzinfo) == "Asia/Tokyo" + + +def test_an_error_timestamp_follows_the_constant(tokyo, respx_mock, client): + """An error the API answered is stamped with the time in the zone the + constant holds.""" + respx_mock.get(QUOTES_URL).respond( + json={"errmsg": "Invalid symbol"}, status_code=400 + ) + + before = datetime.datetime.now(tokyo).strftime(STAMP) + with pytest.raises(BadRequestError) as caught: + client.stocks.quotes(symbols=["AAPL"], output_format=OutputFormat.INTERNAL) + after = datetime.datetime.now(tokyo).strftime(STAMP) + + assert before <= caught.value.timestamp <= after