Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/marketdata/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/marketdata/internal_settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime

import httpx
import pytz
from httpx import Response


Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions src/marketdata/output_handlers/pandas.py
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions src/marketdata/output_handlers/polars.py
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions src/marketdata/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from pytz.exceptions import InvalidTimeError

from marketdata.utils import DEFAULT_TIMEZONE
from marketdata.internal_settings import _DEFAULT_TIMEZONE


@dataclass
Expand Down Expand Up @@ -36,15 +36,15 @@ 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
if reset_time.tzinfo is None:
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 "
Expand Down
9 changes: 3 additions & 6 deletions src/marketdata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
111 changes: 111 additions & 0 deletions src/tests/test_default_timezone.py
Original file line number Diff line number Diff line change
@@ -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
Loading