From 1a1458153f1d706dcc5e2af23a03637cc6618a12 Mon Sep 17 00:00:00 2001 From: EarningsCall Date: Sun, 18 Oct 2026 15:01:49 -0500 Subject: [PATCH 1/2] feat: add get_live_events() for the /live endpoint The API advertises /live (the earnings calls that are live right now), but the SDK had no way to call it. Add get_live_events(), which returns LiveEvent objects with an HLS streaming_url and a parsed event_date. Demo keys receive the API's fixed example event, and the list is empty when nothing is live. Each event's live_transcript links (poll URL, WebSocket URL, token) are marked experimental by the API ("It may change. Do not rely on it."), so LiveTranscriptLinks makes every field optional and ignores unknown fields: a server-side change can't break parsing. Bump version to 3.2.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + CLAUDE.md | 4 +- README.md | 25 ++++ earningscall/__init__.py | 3 +- earningscall/api.py | 6 + earningscall/exports.py | 13 +++ earningscall/live.py | 48 ++++++++ pyproject.toml | 2 +- tests/data/live-events-demo-schema-v4.yaml | 26 +++++ tests/test_get_live_events.py | 130 +++++++++++++++++++++ 10 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 earningscall/live.py create mode 100644 tests/data/live-events-demo-schema-v4.yaml create mode 100644 tests/test_get_live_events.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb009d..2bbb391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Release `3.2.0` - 2026-10-18 + +* Add `get_live_events()`, which returns the earnings calls that are live right now as `LiveEvent` objects, each with an HLS `streaming_url`. The `live_transcript` links on each event are experimental and may change. + ## Release `3.1.0` - 2026-10-18 * Switch to the new API endpoint, `https://api.earningscall.dev`. Override it with the `EARNINGSCALL_API_BASE` environment variable. diff --git a/CLAUDE.md b/CLAUDE.md index 4106763..5748530 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Auth is an `apikey` query param resolved from `earningscall.api_key`, then the `ECALL_API_KEY` / `EARNINGSCALL_API_KEY` env vars, defaulting to `"demo"` (AAPL and MSFT sample access; other symbols raise `InsufficientApiAccessError`). Public surface (see `earningscall/__init__.py`): -`get_company`, `get_all_companies`, `get_sp500_companies`, `get_calendar`, +`get_company`, `get_all_companies`, `get_sp500_companies`, `get_calendar`, `get_live_events`, `Symbols`, `load_symbols`, `exchanges`, and module-level config (`api_key`, `enable_requests_cache`, `retry_strategy`). @@ -31,7 +31,7 @@ vars, defaulting to `"demo"` (AAPL and MSFT sample access; other symbols raise tab-separated `symbols-v2.txt` index; cached in a module-level singleton. - `earningscall/exchanges.py`, `sectors.py` — index↔name tables; exchanges load dynamically from `exchanges.json` with a hardcoded fallback list. -- `earningscall/transcript.py`, `event.py`, `calendar.py` — `dataclasses_json` +- `earningscall/transcript.py`, `event.py`, `calendar.py`, `live.py` — `dataclasses_json` response models. `errors.py` — exception types. - `tests/` — pytest + `responses`; recorded YAML fixtures in `tests/data`, no live network. `scripts/` — runnable examples (`python -m scripts.`). diff --git a/README.md b/README.md index 8818561..02da21b 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,31 @@ KORU Medical Systems, Inc. - Q4 2024 on: 2025-01-10T15:30:00-06:00 Transcript Re WD-40 Company - Q1 2025 on: 2025-01-10T16:00:00-06:00 Transcript Ready: True ``` +## Get Live Earnings Calls + +Get the earnings conference calls that are live right now. Each one includes a `streaming_url`, an HLS playlist (`.m3u8`) you can play with any HLS-capable player. + +```python +from earningscall import get_live_events + +for event in get_live_events(): + print(f"{event.company_name} ({event.exchange}: {event.symbol}) Q{event.quarter} {event.year}") + print(f" Started: {event.event_date.astimezone().isoformat()}") + print(f" Listen: {event.streaming_url}") +``` + +Output + +```text +Apple Inc. (NASDAQ: AAPL) Q4 2024 + Started: 2024-11-01T12:00:00-05:00 + Listen: https://earningscall.biz/live-demo/dynamic-playlist/playlist.m3u8 +``` + +`get_live_events()` returns an empty list when no calls are live. Demo accounts always receive the example event above. + +Each event also has a `live_transcript` field with links to a provisional transcript of the call in progress. This feature is experimental: its fields may change or be removed, so don't rely on it. + ## List All Companies ```python diff --git a/earningscall/__init__.py b/earningscall/__init__.py index 85c4303..66339c1 100644 --- a/earningscall/__init__.py +++ b/earningscall/__init__.py @@ -1,6 +1,6 @@ from typing import Dict, Optional, Union -from earningscall.exports import get_company, get_all_companies, get_sp500_companies, get_calendar +from earningscall.exports import get_company, get_all_companies, get_sp500_companies, get_calendar, get_live_events import earningscall.exchanges as exchanges from earningscall.symbols import Symbols, load_symbols @@ -15,5 +15,6 @@ "Symbols", "load_symbols", "get_calendar", + "get_live_events", "exchanges", ] diff --git a/earningscall/api.py b/earningscall/api.py index 024382e..5d40bd4 100644 --- a/earningscall/api.py +++ b/earningscall/api.py @@ -227,6 +227,12 @@ def get_calendar_api_operation(year: int, month: int, day: int) -> dict: return response.json() +def get_live_events() -> dict: + response = do_get("live") + response.raise_for_status() + return response.json() + + def get_events(exchange: str, symbol: str) -> dict: params = { "exchange": exchange, diff --git a/earningscall/exports.py b/earningscall/exports.py index fe24328..2d61a9e 100644 --- a/earningscall/exports.py +++ b/earningscall/exports.py @@ -9,6 +9,7 @@ from earningscall.calendar import CalendarEvent from earningscall.company import Company from earningscall.errors import InsufficientApiAccessError +from earningscall.live import LiveEvent from earningscall.symbols import get_symbols @@ -86,3 +87,15 @@ def get_calendar(input_date: date) -> List[CalendarEvent]: # Calendar Date not found, simply return an empty list return [] raise error + + +def get_live_events() -> List[LiveEvent]: + """ + Get the earnings conference calls that are live right now. + + Demo accounts receive a fixed example event. + + :return: A list of LiveEvent objects, empty when no calls are live. + """ + json_data = api.get_live_events() + return [LiveEvent.from_dict(event) for event in json_data["events"]] # type: ignore diff --git a/earningscall/live.py b/earningscall/live.py new file mode 100644 index 0000000..cdfa196 --- /dev/null +++ b/earningscall/live.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from dataclasses_json import config +from dataclasses_json import dataclass_json +from marshmallow import fields + + +@dataclass_json +@dataclass +class LiveTranscriptLinks: + """ + Links to the provisional transcript of a call that is in progress. + + Experimental: the API may change or remove these fields, so every field is optional. + """ + + experimental: Optional[bool] = field(default=None) + note: Optional[str] = field(default=None) + token: Optional[str] = field(default=None) + poll_url: Optional[str] = field(default=None) + websocket_url: Optional[str] = field(default=None) + websocket_action: Optional[str] = field(default=None) + + +@dataclass_json +@dataclass +class LiveEvent: + """ + LiveEvent + """ + + exchange: str + symbol: str + year: int + quarter: int + company_name: str + streaming_url: str + event_date: Optional[datetime] = field( + default=None, + metadata=config( + encoder=lambda date: date.isoformat() if date else None, + decoder=lambda date: datetime.fromisoformat(date) if date else None, + mm_field=fields.DateTime(format="iso"), + ), + ) + live_transcript: Optional[LiveTranscriptLinks] = field(default=None) diff --git a/pyproject.toml b/pyproject.toml index ac199bc..3b0238d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "earningscall" -version = "3.1.0" +version = "3.2.0" description = "The EarningsCall Python library provides convenient access to the EarningsCall API. It includes a pre-defined set of classes for API resources that initialize themselves dynamically from API responses." readme = "README.md" authors = [{ name = "EarningsCall", email = "dev@earningscall.biz" }] diff --git a/tests/data/live-events-demo-schema-v4.yaml b/tests/data/live-events-demo-schema-v4.yaml new file mode 100644 index 0000000..79e084f --- /dev/null +++ b/tests/data/live-events-demo-schema-v4.yaml @@ -0,0 +1,26 @@ +responses: +- response: + auto_calculate_content_length: false + body: '{"events":[{"exchange":"NASDAQ","symbol":"AAPL","year":2024,"quarter":4,"company_name":"Apple + Inc.","event_date":"2024-11-01T17:00:00+00:00","streaming_url":"https://earningscall.biz/live-demo/dynamic-playlist/playlist.m3u8","live_transcript":null}]}' + content_type: application/json + headers: + Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, + apikey, x-api-key, x-api-version-schema, x-auth-token, x-auth-type, x-client-build-version, + x-client-commit-id, x-client-device-type, x-behavior-signal, x-request-id + Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS + Access-Control-Allow-Origin: '*' + Access-Control-Expose-Headers: x-trial-calls-remaining, x-trial-expires, x-ratelimit-remaining, + x-api-version-schema, x-request-id, retry-after, x-region, x-fleet + Access-Control-Max-Age: '3600' + Cache-Control: max-age=60 + Vary: Accept-Encoding, x-api-version-schema + X-Api-Version-Schema: v4 + X-Fleet: atlas + X-Plan-Type: demo + X-Ratelimit-Remaining: '89' + X-Region: US East + X-Request-Id: 548c83bb-202f-403c-a654-80739b8f3ac4 + method: GET + status: 200 + url: https://api.earningscall.dev/live?apikey=demo diff --git a/tests/test_get_live_events.py b/tests/test_get_live_events.py new file mode 100644 index 0000000..5a54a57 --- /dev/null +++ b/tests/test_get_live_events.py @@ -0,0 +1,130 @@ +import pytest +import requests +import responses + +import earningscall +from earningscall import get_live_events +from earningscall.api import API_BASE, purge_cache +from earningscall.live import LiveEvent, LiveTranscriptLinks +from earningscall.utils import data_path + +# Uncomment and run following code to generate data/live-events-demo-schema-v4.yaml file +# +# import requests +# from responses import _recorder +# +# +# @_recorder.record(file_path="data/live-events-demo-schema-v4.yaml") +# def test_save_live_events(): +# requests.get("https://api.earningscall.dev/live?apikey=demo", headers={"X-API-Version-Schema": "v4"}) + + +@pytest.fixture(autouse=True) +def run_before_and_after_tests(): + """Fixture to execute asserts before and after a test is run""" + # Setup + earningscall.api_key = None + earningscall.retry_strategy = { + "strategy": "exponential", + "base_delay": 0.01, + "max_attempts": 1, + } + purge_cache() + yield # this is where the testing happens + # Teardown + earningscall.api_key = None + earningscall.retry_strategy = None + + +@responses.activate +def test_get_live_events_demo(): + responses._add_from_file(file_path=data_path("live-events-demo-schema-v4.yaml")) + ## + events = get_live_events() + ## + assert responses.calls[0].request.headers["X-API-Version-Schema"] == "v4" + assert len(events) == 1 + assert events[0].exchange == "NASDAQ" + assert events[0].symbol == "AAPL" + assert events[0].year == 2024 + assert events[0].quarter == 4 + assert events[0].company_name == "Apple Inc." + assert events[0].event_date.isoformat() == "2024-11-01T17:00:00+00:00" + assert events[0].streaming_url == "https://earningscall.biz/live-demo/dynamic-playlist/playlist.m3u8" + assert events[0].live_transcript is None + + +@responses.activate +def test_get_live_events_with_live_transcript(): + earningscall.api_key = "XXXXXXXXXXX" + responses.add( + responses.GET, + f"{API_BASE}/live", + json={ + "events": [ + { + "exchange": "NYSE", + "symbol": "JPM", + "year": 2026, + "quarter": 3, + "company_name": "JP Morgan Chase & Co.", + "event_date": "2026-10-14T07:00:00-04:00", + "streaming_url": "https://earningscall.biz/live/abc123/playlist.m3u8", + "live_transcript": { + "experimental": True, + "note": "The live transcript feature is experimental. It may change. Do not rely on it.", + "token": "abc123", + "poll_url": "https://earningscall.biz/live/abc123/transcript", + "websocket_url": "wss://ws.earningscall.biz", + "websocket_action": "live-transcript", + }, + } + ] + }, + ) + ## + events = get_live_events() + ## + assert len(events) == 1 + assert events[0].symbol == "JPM" + assert events[0].event_date.isoformat() == "2026-10-14T07:00:00-04:00" + assert events[0].live_transcript == LiveTranscriptLinks( + experimental=True, + note="The live transcript feature is experimental. It may change. Do not rely on it.", + token="abc123", + poll_url="https://earningscall.biz/live/abc123/transcript", + websocket_url="wss://ws.earningscall.biz", + websocket_action="live-transcript", + ) + + +def test_live_transcript_tolerates_changed_fields(): + # The live transcript links are experimental, so added or removed fields must not break parsing. + event = LiveEvent.from_dict( + { + "exchange": "NASDAQ", + "symbol": "MSFT", + "year": 2026, + "quarter": 1, + "company_name": "Microsoft Corporation", + "event_date": "2026-10-28T17:30:00-04:00", + "streaming_url": "https://earningscall.biz/live/def456/playlist.m3u8", + "live_transcript": {"experimental": True, "some_future_field": "value"}, + } + ) + assert event.live_transcript == LiveTranscriptLinks(experimental=True) + + +@responses.activate +def test_get_live_events_none_live(): + earningscall.api_key = "XXXXXXXXXXX" + responses.add(responses.GET, f"{API_BASE}/live", json={"events": []}) + assert get_live_events() == [] + + +@responses.activate +def test_get_live_events_raises_on_500(): + earningscall.api_key = "XXXXXXXXXXX" + responses.add(responses.GET, f"{API_BASE}/live", status=500) + with pytest.raises(requests.HTTPError, match="500 Server Error"): + get_live_events() From 6bb28541a8de570bcbc3cbe86fb2031e78bf3aff Mon Sep 17 00:00:00 2001 From: EarningsCall Date: Sun, 18 Oct 2026 15:25:47 -0500 Subject: [PATCH 2/2] fix: keep live events parsing when live_transcript changes shape live_transcript is experimental and exempt from the v4 schema freeze, so the API may change it without notice. Field-level changes (added, removed, renamed, or null fields) already parsed, but replacing the object with a list, string, or boolean raised AttributeError and failed the whole get_live_events() call. Decode it only when it is an object and drop it otherwise, so the live events still load. Also guard the README example against a missing event_date (CodeRabbit review on #50). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 5 +++-- earningscall/live.py | 13 ++++++++++++- tests/test_get_live_events.py | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbb391..755e4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Release `3.2.0` - 2026-10-18 -* Add `get_live_events()`, which returns the earnings calls that are live right now as `LiveEvent` objects, each with an HLS `streaming_url`. The `live_transcript` links on each event are experimental and may change. +* Add `get_live_events()`, which returns the earnings calls that are live right now as `LiveEvent` objects, each with an HLS `streaming_url`. The `live_transcript` links on each event are experimental and may change. Missing fields become `None`, and a `live_transcript` that is not an object is dropped rather than failing the whole call. ## Release `3.1.0` - 2026-10-18 diff --git a/README.md b/README.md index 02da21b..bc99b3b 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,8 @@ from earningscall import get_live_events for event in get_live_events(): print(f"{event.company_name} ({event.exchange}: {event.symbol}) Q{event.quarter} {event.year}") - print(f" Started: {event.event_date.astimezone().isoformat()}") + if event.event_date is not None: + print(f" Started: {event.event_date.astimezone().isoformat()}") print(f" Listen: {event.streaming_url}") ``` @@ -272,7 +273,7 @@ Apple Inc. (NASDAQ: AAPL) Q4 2024 `get_live_events()` returns an empty list when no calls are live. Demo accounts always receive the example event above. -Each event also has a `live_transcript` field with links to a provisional transcript of the call in progress. This feature is experimental: its fields may change or be removed, so don't rely on it. +Each event also has a `live_transcript` field with links to a provisional transcript of the call in progress. This feature is experimental: its fields may change or be removed, so don't rely on it. Any field the API stops sending becomes `None`, and if `live_transcript` stops being an object, it becomes `None` too, so the live events themselves still load. ## List All Companies diff --git a/earningscall/live.py b/earningscall/live.py index cdfa196..bdb2b53 100644 --- a/earningscall/live.py +++ b/earningscall/live.py @@ -45,4 +45,15 @@ class LiveEvent: mm_field=fields.DateTime(format="iso"), ), ) - live_transcript: Optional[LiveTranscriptLinks] = field(default=None) + live_transcript: Optional[LiveTranscriptLinks] = field( + default=None, + metadata=config(decoder=lambda links: _decode_live_transcript(links)), + ) + + +def _decode_live_transcript(links) -> Optional[LiveTranscriptLinks]: + # The links are experimental: if the API stops sending an object here, drop them rather + # than failing to parse every live event. + if not isinstance(links, dict): + return None + return LiveTranscriptLinks.from_dict(links) # type: ignore diff --git a/tests/test_get_live_events.py b/tests/test_get_live_events.py index 5a54a57..c6a756d 100644 --- a/tests/test_get_live_events.py +++ b/tests/test_get_live_events.py @@ -115,6 +115,30 @@ def test_live_transcript_tolerates_changed_fields(): assert event.live_transcript == LiveTranscriptLinks(experimental=True) +@pytest.mark.parametrize("live_transcript", [None, [{"poll_url": "x"}], "https://example.com/transcript", True]) +@responses.activate +def test_get_live_events_drops_live_transcript_that_is_not_an_object(live_transcript): + # If the experimental links change from an object to something else, the events still parse. + earningscall.api_key = "XXXXXXXXXXX" + event = { + "exchange": "NASDAQ", + "symbol": "MSFT", + "year": 2026, + "quarter": 1, + "company_name": "Microsoft Corporation", + "event_date": "2026-10-28T17:30:00-04:00", + "streaming_url": "https://earningscall.biz/live/def456/playlist.m3u8", + "live_transcript": live_transcript, + } + responses.add(responses.GET, f"{API_BASE}/live", json={"events": [event]}) + ## + events = get_live_events() + ## + assert len(events) == 1 + assert events[0].symbol == "MSFT" + assert events[0].live_transcript is None + + @responses.activate def test_get_live_events_none_live(): earningscall.api_key = "XXXXXXXXXXX"