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