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
6 changes: 6 additions & 0 deletions src/marketdata/resources/options/expirations.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,19 @@ 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,
input_params=input_params,
extra_params=kwargs,
excluded_params=["symbol"],
)
url += "&dateformat=unix"
self.logger.debug("Fetching options expirations...")

response = self.client._make_request(method="GET", url=url)
Expand Down
26 changes: 14 additions & 12 deletions src/marketdata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
44 changes: 22 additions & 22 deletions src/tests/data/options_expirations_human_response_200.json
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 22 additions & 22 deletions src/tests/data/options_expirations_response_200.json
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 26 additions & 20 deletions src/tests/test_options_expirations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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(
Expand All @@ -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
)


Expand All @@ -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
)


Expand Down
22 changes: 22 additions & 0 deletions src/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down