Spaces:
Running on Zero
Running on Zero
File size: 7,678 Bytes
aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f aca7f42 f86548f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """US earnings-call transcripts from the kurry/sp500_earnings_transcripts dataset.
33,362 transcripts across 685 S&P 500 companies, 2005-2025, MIT-licensed. This is the
US counterpart to the BSE concall PDFs on the Indian side — except the text is
already speaker-tagged, so management commentary and analyst Q&A are separable
without parsing a PDF.
The parquet is ~1.8 GB (two row groups, the first holding 32,768 rows), so it is never
loaded whole. Listings read only the five metadata columns (milliseconds). The text of
one company is extracted by streaming the file in batches and keeping that symbol's
rows, which holds a few hundred rows in memory instead of the file.
"""
from __future__ import annotations
import functools
import re
REPO = "kurry/sp500_earnings_transcripts"
FILE = "parquet_files/part-0.parquet"
COVERAGE = (f"{REPO} (MIT): about 33,000 earnings calls for 685 S&P 500 companies, "
"2005-2025; year and quarter are the dataset's own labels")
_META_COLS = ["symbol", "quarter", "year", "date", "company_name"]
_path_cache: str | None = None
def _path() -> str | None:
"""Local path of the parquet, downloading on first use (~1.8 GB, cached by HF).
A failed download is not remembered, so a transient network error does not
disable the tool until restart."""
global _path_cache
if _path_cache:
return _path_cache
try:
from huggingface_hub import hf_hub_download
_path_cache = hf_hub_download(REPO, FILE, repo_type="dataset")
except Exception:
return None
return _path_cache
def available() -> bool:
return _path() is not None
@functools.lru_cache(maxsize=1)
def _meta_cached(path: str):
import pyarrow.parquet as pq
df = pq.read_table(path, columns=_META_COLS).to_pandas()
df["date"] = df["date"].astype(str).str[:10]
return df
def _meta():
p = _path()
return _meta_cached(p) if p else None
def symbols() -> frozenset:
m = _meta()
return frozenset(m["symbol"].dropna()) if m is not None else frozenset()
def listing(symbol: str, year: int | None = None, limit: int = 100) -> list[dict]:
"""Calls available for a symbol, newest first — metadata only, not the text."""
m = _meta()
if m is None:
return []
df = m[m["symbol"] == symbol.upper()]
if year is not None:
df = df[df["year"] == int(year)]
df = df.sort_values(["date", "year", "quarter"], ascending=False).head(limit)
return [{"company": r.company_name, "year": int(r.year), "quarter": int(r.quarter),
"date": r.date} for r in df.itertuples()]
@functools.lru_cache(maxsize=8)
def _calls(path: str, symbol: str):
"""Every call of one symbol, with speaker turns. Streams the file in batches."""
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
cols = _META_COLS + ["structured_content"]
parts = []
for batch in pq.ParquetFile(path).iter_batches(batch_size=256, columns=cols):
hit = pc.equal(batch.column("symbol"), symbol)
if pc.any(hit).as_py():
parts.append(batch.filter(hit))
if not parts:
return None
df = pa.Table.from_batches(parts).to_pandas()
df["date"] = df["date"].astype(str).str[:10]
return df.sort_values(["date"], ascending=False).reset_index(drop=True)
# Older transcripts open with participant lists filed as "speakers".
_LIST_HEADS = {"executives", "analysts", "participants", "company participants",
"corporate participants", "conference call participants", "management",
"attendees", "presenters", "other participants"}
_OPERATOR_INTRO = re.compile(
r"(?i)(my name is|i(?:'ll| will) be your|i am your|this is)\b.{0,80}\b"
r"(operator|facilitator|moderator|coordinator)")
def _speaker(t: dict) -> str:
return str(t.get("speaker", "")).strip().lower()
def qa_start(turns: list[dict]) -> int | None:
"""Index of the turn where the Q&A begins, or None if it cannot be found.
A call opens with the operator (sometimes after participant lists), then the host
and the prepared remarks; the first operator turn after a real speaker has
spoken hands over to the questions ("[Operator Instructions]", "our first
question comes from ..."). The operator is the speaker named Operator or, as in
NVIDIA's calls, the first speaker if that turn introduces themselves as the
conference operator. On a sample of 2,200 calls a boundary was found in 99% and
the turn at it reads as a Q&A hand-over in 98.7% of those; the rest are
operator interjections during the prepared remarks or empty turns, which is why
the output names the turn it split at.
"""
ops = {_speaker(t) for t in turns if _speaker(t).startswith("operator")}
for t in turns[:4]:
if _speaker(t) not in _LIST_HEADS and _OPERATOR_INTRO.search(str(t.get("text", ""))[:600]):
ops.add(_speaker(t))
break
spoken = False
for i, t in enumerate(turns):
s = _speaker(t)
if s in _LIST_HEADS or s.endswith("participants"):
continue
if s in ops:
if spoken:
return i
continue
spoken = True
return None
def get(symbol: str, year: int | None = None, quarter: int | None = None) -> dict | None:
"""One call with its turns; the most recent when year/quarter are omitted."""
p = _path()
if not p:
return None
df = _calls(p, symbol.upper())
if df is None:
return None
if year is not None:
df = df[df["year"] == int(year)]
if quarter is not None:
df = df[df["quarter"] == int(quarter)]
if df.empty:
return None
r = df.iloc[0] # newest by call date
sc = r["structured_content"]
turns = [{"speaker": t.get("speaker"), "text": t.get("text")}
for t in (list(sc) if sc is not None else [])]
return {"symbol": r["symbol"], "company": r["company_name"], "year": int(r["year"]),
"quarter": int(r["quarter"]), "date": r["date"], "turns": turns,
"matches": int(len(df)), "source": f"{REPO} (MIT)"}
def _boundary(turns: list[dict], start: int) -> str:
"""The turn the split was made at, quoted so a wrong split is visible."""
t = turns[start]
return (f"Q&A taken to start at turn {start + 1} ({t.get('speaker')}: "
f"\"{re.sub(r'\s+', ' ', str(t.get('text', '')))[:80]}\")")
def render(call: dict, section: str = "full") -> tuple[str | None, str]:
"""(text, note) for one section of a call. text is None, with the reason in
note, when the requested section cannot be separated."""
turns = call["turns"]
if not turns:
return None, "the dataset holds no speaker-tagged text for this call"
start = qa_start(turns)
if section == "full":
picked, note = turns, ""
elif section not in ("prepared_remarks", "qa"):
return None, f"unknown section '{section}'; one of full | prepared_remarks | qa"
elif start is None:
return None, ("the Q&A boundary could not be found in this transcript (no "
"operator turn after the opening); use section='full'")
elif section == "prepared_remarks":
picked = turns[:start]
note = f"turns 1-{start} of {len(turns)}; {_boundary(turns, start)}"
else:
picked = turns[start:]
note = f"turns {start + 1}-{len(turns)} of {len(turns)}; {_boundary(turns, start)}"
text = "\n\n".join(f"{t['speaker']}: {re.sub(r'[ \t]+', ' ', str(t['text'])).strip()}"
for t in picked)
return text, note
|