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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand All @@ -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.<name>`).
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion earningscall/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -15,5 +15,6 @@
"Symbols",
"load_symbols",
"get_calendar",
"get_live_events",
"exchanges",
]
6 changes: 6 additions & 0 deletions earningscall/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions earningscall/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
59 changes: 59 additions & 0 deletions earningscall/live.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }]
Expand Down
26 changes: 26 additions & 0 deletions tests/data/live-events-demo-schema-v4.yaml
Original file line number Diff line number Diff line change
@@ -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
154 changes: 154 additions & 0 deletions tests/test_get_live_events.py
Original file line number Diff line number Diff line change
@@ -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()
Loading