File size: 11,902 Bytes
5d2eba0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
"""
features/earnings_sentiment.py โ€” Earnings Call Sentiment Intelligence
Analyzes earnings call transcripts for sentiment, confidence, guidance tone.
"""
import streamlit as st
import json
import re
import logging
from datetime import datetime, timedelta

logger = logging.getLogger("EarningsSentiment")


# ---------------------------------------------------------------------------
# Transcript fetching
# ---------------------------------------------------------------------------
def _fetch_transcript(ticker: str, quarter: int = None, year: int = None) -> str:
    """Fetch earnings call transcript via Tavily search."""
    from features.utils import run_tavily_search

    now = datetime.now()
    if not quarter:
        quarter = (now.month - 1) // 3 + 1
        # Last quarter
        if quarter == 1:
            quarter = 4
            year = (year or now.year) - 1
        else:
            quarter -= 1
    if not year:
        year = now.year

    query = f"{ticker} earnings call transcript Q{quarter} {year}"
    try:
        result = run_tavily_search(query, search_depth="advanced")
        texts = []
        for qr in result.get("data", []):
            for r in qr.get("results", []):
                texts.append(r.get("content", ""))
        return "\n\n".join(texts[:5]) if texts else ""
    except Exception as e:
        logger.error(f"Transcript fetch failed: {e}")
        return ""


# ---------------------------------------------------------------------------
# Sentiment analysis via Gemini
# ---------------------------------------------------------------------------
def _analyze_sentiment(ticker: str, transcript: str) -> dict:
    """Run Gemini to analyze sentiment of earnings call."""
    from features.utils import call_gemini

    prompt = f"""You are an expert sentiment analyst specializing in earnings.

Analyze the following text regarding the earnings call for {ticker}.
Note: The text may be the raw transcript OR market commentary/news about the call. 
Analyze whatever is provided to determine the sentiment, guidance, and key themes as accurately as possible.

---
{transcript[:6000]}
---

Provide your analysis as a VALID JSON object with this exact structure:
{{
    "management_sentiment": {{
        "score": <float from -1.0 to 1.0>,
        "label": "Positive" | "Neutral" | "Negative",
        "confidence_level": <int from 0-100>,
        "forward_guidance": "Optimistic" | "Cautious" | "Withdrawn",
        "key_quotes": ["quote1", "quote2"]
    }},
    "qa_sentiment": {{
        "score": <float from -1.0 to 1.0>,
        "label": "Positive" | "Neutral" | "Negative",
        "confidence_level": <int from 0-100>,
        "analyst_concerns": ["concern1", "concern2"]
    }},
    "key_themes": ["theme1", "theme2", "theme3", "theme4", "theme5"],
    "positive_words": ["word1", "word2", "word3", "word4", "word5", "word6", "word7", "word8"],
    "negative_words": ["word1", "word2", "word3", "word4", "word5", "word6", "word7", "word8"],
    "divergence_alerts": ["alert1 if any"],
    "between_the_lines": "A 2-3 paragraph analysis of what management is really communicating between the lines."
}}

Be precise with scores. Detect hedging language, overconfidence, and tone shifts.
Return ONLY the JSON, no markdown formatting."""

    raw = call_gemini(prompt, "You are a senior NLP analyst at a hedge fund specializing in earnings call analysis.")

    # Force JSON format cleanup if AI included markdown blocks
    raw = raw.replace("```json", "").replace("```", "").strip()

    try:
        # Match from first { to last }
        json_match = re.search(r'\{.*\}', raw, re.DOTALL)
        if json_match:
            return json.loads(json_match.group(0))
    except (json.JSONDecodeError, ValueError) as e:
        logger.error(f"Sentiment parse error: {e}")

    # Fallback structure
    return {
        "management_sentiment": {"score": 0, "label": "Neutral", "confidence_level": 50, "forward_guidance": "Cautious", "key_quotes": []},
        "qa_sentiment": {"score": 0, "label": "Neutral", "confidence_level": 50, "analyst_concerns": []},
        "key_themes": ["Unable to parse"],
        "positive_words": [], "negative_words": [],
        "divergence_alerts": [],
        "between_the_lines": raw,
    }


# ---------------------------------------------------------------------------
# Visualization helpers
# ---------------------------------------------------------------------------
def _render_gauge(score: float, label: str, title: str):
    """Render a Plotly gauge chart for sentiment score."""
    import plotly.graph_objects as go

    color = "#10b981" if score > 0.2 else "#ef4444" if score < -0.2 else "#f59e0b"
    fig = go.Figure(go.Indicator(
        mode="gauge+number+delta",
        value=score,
        title={"text": title, "font": {"size": 16, "color": "white"}},
        number={"font": {"color": "white"}},
        gauge={
            "axis": {"range": [-1, 1], "tickcolor": "white"},
            "bar": {"color": color},
            "bgcolor": "#1e1e1e",
            "bordercolor": "#333",
            "steps": [
                {"range": [-1, -0.3], "color": "rgba(239,68,68,0.2)"},
                {"range": [-0.3, 0.3], "color": "rgba(245,158,11,0.2)"},
                {"range": [0.3, 1], "color": "rgba(16,185,129,0.2)"},
            ],
        },
    ))
    fig.update_layout(
        paper_bgcolor="rgba(0,0,0,0)", font_color="white",
        height=250, margin=dict(l=20, r=20, t=50, b=20),
    )
    return fig


def _render_wordcloud(words: list, title: str, colormap: str = "Greens"):
    """Generate a word cloud image from a list of words."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from wordcloud import WordCloud

    if not words:
        return None

    text = " ".join(words)
    wc = WordCloud(
        width=400, height=200, background_color="black",
        colormap=colormap, max_words=50, prefer_horizontal=0.7,
    ).generate(text)

    fig, ax = plt.subplots(figsize=(6, 3))
    ax.imshow(wc, interpolation="bilinear")
    ax.axis("off")
    ax.set_title(title, color="white", fontsize=12, pad=10)
    fig.patch.set_facecolor("black")
    plt.tight_layout()
    return fig


# ---------------------------------------------------------------------------
# Streamlit page renderer
# ---------------------------------------------------------------------------
def render_earnings_sentiment():
    st.markdown("## ๐ŸŽ™๏ธ Earnings Call Sentiment Intelligence")
    st.caption("Analyze earnings call transcripts for hidden sentiment signals, management confidence, "
               "and forward guidance shifts that predict future price moves.")

    col1, col2, col3 = st.columns([2, 1, 1])
    with col1:
        ticker = st.text_input("Ticker Symbol:", placeholder="e.g. AAPL", key="es_ticker").upper().strip()
    with col2:
        quarter = st.selectbox("Quarter:", [None, 1, 2, 3, 4], format_func=lambda x: f"Q{x}" if x else "Auto-detect", key="es_q")
    with col3:
        year = st.number_input("Year:", min_value=2020, max_value=2026, value=datetime.now().year, key="es_year")

    analyze_btn = st.button("๐Ÿ” Analyze Earnings Call", use_container_width=True, key="es_analyze")

    if analyze_btn and ticker:
        with st.status("๐ŸŽ™๏ธ Analyzing earnings call...", expanded=True) as status:
            status.write(f"๐Ÿ“ก Searching for {ticker} Q{quarter or 'latest'} {year} transcript...")
            transcript = _fetch_transcript(ticker, quarter, year)

            if not transcript:
                status.update(label="โš ๏ธ No transcript found", state="error")
                st.warning(f"Could not find earnings call transcript for {ticker}. "
                           "Try specifying a different quarter or year.")
                return

            status.write("๐Ÿง  Running deep sentiment analysis...")
            analysis = _analyze_sentiment(ticker, transcript)
            st.session_state["es_analysis"] = analysis
            st.session_state["es_display_ticker"] = ticker
            status.update(label="โœ… Analysis Complete!", state="complete", expanded=False)

    # Display results
    analysis = st.session_state.get("es_analysis")
    if not analysis:
        return

    ticker_display = st.session_state.get("es_display_ticker", "")
    st.markdown(f"### ๐Ÿ“Š Sentiment Analysis: **{ticker_display}**")

    mgmt = analysis.get("management_sentiment", {})
    qa = analysis.get("qa_sentiment", {})

    # Side-by-side gauges
    col1, col2 = st.columns(2)
    with col1:
        st.markdown("#### ๐ŸŽค Management Prepared Remarks")
        fig = _render_gauge(mgmt.get("score", 0), mgmt.get("label", "N/A"), "Management Sentiment")
        st.plotly_chart(fig, use_container_width=True)

        st.markdown(f"**Confidence Level:** {mgmt.get('confidence_level', 'N/A')}/100")
        st.markdown(f"**Forward Guidance:** {mgmt.get('forward_guidance', 'N/A')}")

        if mgmt.get("key_quotes"):
            st.markdown("**Key Quotes:**")
            for q in mgmt["key_quotes"]:
                st.markdown(f'> *"{q}"*')

    with col2:
        st.markdown("#### โ“ Q&A Session")
        fig = _render_gauge(qa.get("score", 0), qa.get("label", "N/A"), "Q&A Sentiment")
        st.plotly_chart(fig, use_container_width=True)

        st.markdown(f"**Confidence Level:** {qa.get('confidence_level', 'N/A')}/100")

        if qa.get("analyst_concerns"):
            st.markdown("**Analyst Concerns:**")
            for c in qa["analyst_concerns"]:
                st.markdown(f"- โš ๏ธ {c}")

    # Key Themes
    st.markdown("---")
    st.markdown("#### ๐Ÿท๏ธ Key Themes Mentioned")
    themes = analysis.get("key_themes", [])
    if themes:
        cols = st.columns(min(len(themes), 5))
        for i, theme in enumerate(themes[:5]):
            with cols[i % 5]:
                st.markdown(f"""
                <div style="background: #1e1e1e; border: 1px solid #333; border-radius: 8px;
                     padding: 12px; text-align: center; margin: 4px 0;">
                    <span style="font-size: 0.9rem; color: #a78bfa;">{theme}</span>
                </div>
                """, unsafe_allow_html=True)

    # Word Clouds
    col1, col2 = st.columns(2)
    with col1:
        fig = _render_wordcloud(analysis.get("positive_words", []), "Positive Language", "Greens")
        if fig:
            st.pyplot(fig)
    with col2:
        fig = _render_wordcloud(analysis.get("negative_words", []), "Negative / Hedging Language", "Reds")
        if fig:
            st.pyplot(fig)

    # Divergence Alerts
    alerts = analysis.get("divergence_alerts", [])
    if alerts:
        st.markdown("---")
        st.markdown("#### ๐Ÿšจ Divergence Alerts")
        for alert in alerts:
            st.error(f"โš ๏ธ {alert}")

    # Between the Lines
    st.markdown("---")
    with st.expander("๐Ÿ”ฎ What Management Is Really Saying", expanded=True):
        st.markdown(analysis.get("between_the_lines", "No analysis available."))

    # PDF Export
    st.markdown("---")
    if st.button("๐Ÿ“ฅ Download Sentiment Report as PDF", key="es_pdf"):
        from features.utils import export_to_pdf
        sections = [
            {"title": f"Earnings Sentiment: {ticker_display}", "body": f"Management: {mgmt.get('label', 'N/A')} ({mgmt.get('score', 0):.2f})\nQ&A: {qa.get('label', 'N/A')} ({qa.get('score', 0):.2f})"},
            {"title": "Key Themes", "body": ", ".join(themes)},
            {"title": "Between the Lines", "body": analysis.get("between_the_lines", "")},
        ]
        pdf_bytes = export_to_pdf(sections, f"{ticker_display}_sentiment.pdf")
        st.download_button("โฌ‡๏ธ Download PDF", data=pdf_bytes,
                           file_name=f"{ticker_display}_Sentiment_Report.pdf",
                           mime="application/pdf", key="es_pdf_dl")