diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb009d..755e4ae 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. 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 * 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..bc99b3b 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,32 @@ 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}") + if event.event_date is not None: + 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. 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 ```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..bdb2b53 --- /dev/null +++ b/earningscall/live.py @@ -0,0 +1,59 @@ +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, + 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/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..c6a756d --- /dev/null +++ b/tests/test_get_live_events.py @@ -0,0 +1,154 @@ +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) + + +@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" + 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()