File size: 19,808 Bytes
35676b4
7880373
35676b4
7880373
 
35676b4
 
 
 
7880373
2401ce5
 
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2401ce5
 
 
 
 
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
 
 
 
 
 
7880373
 
 
 
 
 
 
35676b4
7880373
 
 
 
 
 
d65dc97
7880373
d65dc97
7880373
d65dc97
7880373
 
 
 
 
 
 
 
d65dc97
7880373
d65dc97
7880373
 
35676b4
7880373
 
d65dc97
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d65dc97
7880373
 
 
 
 
35676b4
 
 
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
35676b4
7880373
 
 
 
35676b4
7880373
 
 
 
 
35676b4
 
 
 
 
 
 
7880373
 
 
 
 
 
 
 
 
 
 
35676b4
7880373
 
 
 
 
 
 
 
 
 
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
5a39d9f
7880373
 
35676b4
 
7880373
 
 
 
 
35676b4
7880373
 
 
 
 
35676b4
 
7880373
 
 
 
 
35676b4
d42057a
 
35676b4
7880373
 
 
 
d42057a
35676b4
d42057a
 
35676b4
 
d42057a
 
35676b4
7880373
 
 
 
 
35676b4
d42057a
 
 
35676b4
 
7880373
 
 
 
d42057a
 
5a39d9f
7880373
 
 
 
 
 
35676b4
d42057a
 
5a39d9f
35676b4
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35676b4
 
 
 
 
 
 
 
 
 
 
 
 
7880373
 
 
2401ce5
 
 
7880373
 
 
 
 
 
 
 
 
2401ce5
7880373
 
 
 
35676b4
7880373
 
35676b4
7880373
 
35676b4
7880373
 
 
 
 
 
 
 
 
35676b4
 
 
 
 
 
7880373
2401ce5
7880373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2401ce5
 
 
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
import os
from typing import Any
from langchain_core.tools import tool
from agent.evidence import evidence_envelope, make_evidence_record, parse_evidence_envelope
from storage import metrics_db, news_cache

TOP_K_FILINGS = 5
TOP_K_TRANSCRIPTS = 4

_tavily_client: Any = None


class _LazyVectorStore:
    """Keep heavy vector dependencies lazy while preserving a patchable seam."""

    @staticmethod
    def search(*args, **kwargs):
        from storage import vector_store as implementation

        return implementation.search(*args, **kwargs)


vector_store = _LazyVectorStore()


def TavilyClient(*args, **kwargs):
    """Lazily construct Tavily's client; kept as a named dependency seam."""
    from tavily import TavilyClient as implementation

    return implementation(*args, **kwargs)


def _empty(tool_name: str, query: dict, message: str) -> str:
    return evidence_envelope(tool=tool_name, query=query, status="EMPTY", message=message)


def _error(tool_name: str, query: dict, exc: Exception) -> str:
    return evidence_envelope(
        tool=tool_name,
        query=query,
        status="ERROR",
        message=f"{type(exc).__name__}: {exc}",
        error_code="TOOL_EXECUTION_ERROR",
    )


def _vector_record(result: dict, fallback_source: str):
    metadata = dict(result.get("metadata") or {})
    source = metadata.get("source") or fallback_source
    metadata.setdefault(
        "chunk_context",
        f"Source: {source} | Section: {metadata.get('section', '')} | Date: {metadata.get('filing_date') or metadata.get('date') or ''}",
    )
    return make_evidence_record(
        source=source,
        content=result.get("text", ""),
        document_id=str(metadata.get("document_id") or ":".join(filter(None, [
            source,
            str(metadata.get("ticker", "")),
            str(metadata.get("period", "")),
            str(metadata.get("filing_date") or metadata.get("date") or ""),
            str(metadata.get("section", "")),
        ]))),
        chunk_id=str(metadata.get("chunk_id")) if metadata.get("chunk_id") else None,
        source_url=metadata.get("source_url"),
        as_of=metadata.get("filing_date") or metadata.get("date"),
        metadata=metadata,
    )


def _get_tavily_client() -> Any:
    global _tavily_client
    if _tavily_client is None:
        _tavily_client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
    return _tavily_client


def _fmt_billions(v) -> str:
    return f"${v / 1e9:.1f}B" if v is not None else "N/A"


def _fmt_pct(v) -> str:
    return f"{v:.1%}" if v is not None else "N/A"


def _fmt_millions(v) -> str:
    return f"${v / 1e6:.0f}M" if v is not None else "N/A"


def _fmt_row(data: dict) -> str:
    yoy = f" (YoY: {data['revenue_yoy_pct']:+.1f}%)" if data.get("revenue_yoy_pct") is not None else ""
    lines = [
        f"  Filing: {data.get('form_type', '')} {data.get('period', '')} | Date: {data['filing_date']}",
        f"  Revenue: {_fmt_billions(data['revenue'])}{yoy}",
        f"  EPS (diluted): {data['eps']:.2f}" if data.get("eps") else "  EPS: N/A",
        f"  Gross Margin: {_fmt_pct(data['gross_margin'])}",
        f"  Operating Margin: {_fmt_pct(data['operating_margin'])}",
        f"  Free Cash Flow: {_fmt_billions(data['free_cash_flow'])}",
    ]
    # Valuation parameter inputs
    shares = data.get("shares_diluted")
    lines.append(f"  Shares Outstanding (diluted): {shares / 1e6:.0f}M" if shares else "  Shares Outstanding: N/A")
    lines.append(f"  Effective Tax Rate: {_fmt_pct(data.get('effective_tax_rate'))}")
    lines.append(f"  Interest Expense: {_fmt_millions(data.get('interest_expense'))}")
    lines.append(f"  Total Debt (LT): {_fmt_billions(data.get('total_debt'))}")
    lines.append(f"  Capex: {_fmt_millions(data.get('capex'))}")
    lines.append(f"  Dividends Paid: {_fmt_millions(data.get('dividends_paid'))}")
    lines.append(f"  Share Buybacks: {_fmt_millions(data.get('buybacks'))}")
    equity = data.get("stockholders_equity")
    debt = data.get("total_debt")
    de_ratio = f" (D/E: {debt / equity:.2f}x)" if (debt and equity and equity > 0) else ""
    lines.append(f"  Stockholders' Equity: {_fmt_billions(equity)}{de_ratio}")
    if data.get("guidance_text"):
        lines.append(f"  Guidance: {data['guidance_text']}")
    warnings = data.get("quality_warnings") or []
    contexts = data.get("metric_contexts") or {}
    lineage = []
    if isinstance(contexts, dict):
        for metric, context in sorted(contexts.items()):
            if not isinstance(context, dict):
                continue
            source = context.get("source") or "derived"
            selection = context.get("selection") or "unspecified"
            concept = context.get("concept")
            detail = f"{metric}={source}/{selection}"
            if concept:
                detail += f"/{concept}"
            lineage.append(detail)
    lines.extend([
        f"  Data Quality: {data.get('data_quality_status') or 'LEGACY_UNVERIFIED'}",
        f"  Accession: {data.get('accession') or 'N/A'} | Report Date: {data.get('report_date') or 'N/A'}",
        f"  Quality Warnings: {', '.join(str(item) for item in warnings) if warnings else 'none'}",
        f"  Metric Lineage: {'; '.join(lineage) if lineage else 'not recorded'}",
    ])
    return "\n".join(lines)


@tool
def get_financial_metrics(ticker: str) -> str:
    """Retrieve structured financial metrics for a ticker across all ingested periods.
    Always call this first to anchor quantitative claims. Returns an evidence.v1
    JSON envelope; cite a record by copying its `ref` into `evidence_ref`."""
    query = {"ticker": ticker.upper()}
    try:
        rows = metrics_db.get_all_metrics(ticker)
    except Exception as exc:
        return _error("get_financial_metrics", query, exc)
    if not rows:
        return _empty(
            "get_financial_metrics",
            query,
            f"Ticker {ticker.upper()} not ingested. Run: python ingest.py {ticker.upper()}",
        )

    usable_rows = [
        row for row in rows
        if row.get("data_quality_status") in {"VERIFIED", "CHECK_REQUIRED"}
    ]
    if not usable_rows:
        statuses = sorted({
            str(row.get("data_quality_status") or "LEGACY_UNVERIFIED")
            for row in rows
        })
        return _empty(
            "get_financial_metrics",
            query,
            (
                f"Metrics exist for {ticker.upper()}, but none carries SEC period lineage "
                f"(statuses: {', '.join(statuses)}). Run: python ingest.py "
                f"{ticker.upper()} --full to restore citable metrics."
            ),
        )

    try:
        records = []
        for row in usable_rows:
            content = f"Company: {row['company_name']} ({row['ticker']})\n{_fmt_row(row)}"
            period = str(row.get("period") or "unknown")
            filing_date = str(row.get("filing_date") or "")
            accession = str(row.get("accession") or "")
            records.append(make_evidence_record(
                source="metrics",
                content=content,
                document_id=(
                    f"metrics:{ticker.upper()}:{accession}"
                    if accession else f"metrics:{ticker.upper()}:{period}:{filing_date}"
                ),
                source_url=row.get("source_url") or None,
                as_of=filing_date or None,
                metadata={
                    "ticker": ticker.upper(),
                    "company_name": row.get("company_name"),
                    "period": period,
                    "filing_date": filing_date,
                    "form_type": row.get("form_type"),
                    "period_basis": row.get("period_basis", "unknown"),
                    "report_date": row.get("report_date"),
                    "accession": row.get("accession"),
                    "source_url": row.get("source_url"),
                    "metric_contexts": row.get("metric_contexts") or {},
                    "quality_warnings": row.get("quality_warnings") or [],
                    "data_quality_status": row.get("data_quality_status") or "LEGACY_UNVERIFIED",
                },
            ))
    except Exception as exc:
        return _error("get_financial_metrics", query, exc)
    return evidence_envelope(tool="get_financial_metrics", query=query, records=records)


@tool
def search_filing(query: str, ticker: str, since: str = "", period: str = "") -> str:
    """Search SEC filings (10-Q and 10-K) for relevant text using semantic search.
    Use for MD&A, risk factors, and outlook sections.
    Use `since` (YYYY-MM-DD) to restrict results to filings after that date.
    Use `period` (e.g. 'Q12024', 'FY2023') to fetch chunks from a specific past period for cross-year/cross-quarter comparison.
    Returns an evidence.v1 JSON envelope; copy the supporting record's `ref` exactly."""
    request = {"query": query, "ticker": ticker.upper(), "since": since, "period": period}
    try:
        results = vector_store.search(
            "filings", query, ticker, n_results=TOP_K_FILINGS,
            min_filing_date=since if since else None,
            period=period if period else None,
        )
    except Exception as exc:
        return _error("search_filing", request, exc)
    if not results:
        return _empty(
            "search_filing",
            request,
            f"No filing data for {ticker.upper()} matching query (period={period or 'any'}). Run: python ingest.py {ticker.upper()}",
        )
    try:
        records = [_vector_record(result, "10-Q") for result in results]
    except Exception as exc:
        return _error("search_filing", request, exc)
    return evidence_envelope(tool="search_filing", query=request, records=records)


@tool
def search_transcript(query: str, ticker: str, since: str = "", period: str = "") -> str:
    """Search earnings call transcripts for management commentary.
    Use for CEO/CFO tone, forward guidance, and analyst Q&A themes.
    Use `since` (YYYY-MM-DD) to restrict results to transcripts after that date.
    Use `period` (e.g. 'Q12024', 'FY2023') to fetch chunks from a specific past period for cross-year/cross-quarter comparison.
    Returns an evidence.v1 JSON envelope; copy the supporting record's `ref` exactly."""
    request = {"query": query, "ticker": ticker.upper(), "since": since, "period": period}
    try:
        results = vector_store.search(
            "transcripts", query, ticker, n_results=TOP_K_TRANSCRIPTS,
            min_filing_date=since if since else None,
            period=period if period else None,
        )
    except Exception as exc:
        return _error("search_transcript", request, exc)
    if not results:
        return _empty(
            "search_transcript",
            request,
            f"No transcript for {ticker.upper()} matching query (period={period or 'any'}). It may not have been ingested yet.",
        )
    try:
        records = [_vector_record(result, "transcript") for result in results]
    except Exception as exc:
        return _error("search_transcript", request, exc)
    return evidence_envelope(tool="search_transcript", query=request, records=records)


def _fmt_revision_signal(pct: float) -> str:
    if pct >= 1.0:
        return "positive momentum"
    if pct <= -1.0:
        return "negative momentum"
    return "stable"


@tool
def get_analyst_expectations(ticker: str) -> str:
    """Fetch analyst consensus EPS / revenue estimates, 30-day estimate revision %,
    and post-earnings d1/d5/since-release stock price reaction. Use to compare reported
    actuals against market expectations and gauge market reception of the latest filing.
    Returns an evidence.v1 JSON envelope with explicit period/event alignment flags."""
    from ingestion.analyst import fetch_analyst_estimates, fetch_price_reaction

    request = {"ticker": ticker.upper()}
    try:
        rows = metrics_db.get_all_metrics(ticker)
        latest_filing_date = rows[0]["filing_date"] if rows else None
        target_period = rows[0].get("period") if rows else None

        est, est_err = fetch_analyst_estimates(ticker, target_period=target_period)
        price, price_err = (fetch_price_reaction(ticker, latest_filing_date)
                             if latest_filing_date else (None, "no filing date in metrics_db"))
    except Exception as exc:
        return _error("get_analyst_expectations", request, exc)

    if est is None and price is None:
        return _empty(
            "get_analyst_expectations",
            request,
            f"No analyst data available for {ticker.upper()}. Errors: {est_err}; {price_err}",
        )

    # Use exact schema field names so the LLM can copy values verbatim without renaming.
    lines = [f"Analyst expectations for {ticker.upper()} (field names match MarketExpectations schema):"]
    if est:
        estimates_aligned = bool(est.get("period_aligned") and est.get("comparison_allowed"))
        eps = est.get("consensus_eps_est") if estimates_aligned else None
        rev = est.get("consensus_rev_est") if estimates_aligned else None
        rev30 = est.get("estimate_revision_30d_pct") if estimates_aligned else None
        lines.append(f"  consensus_eps_est: {eps:.4f}" if eps is not None else "  consensus_eps_est: null")
        lines.append(
            f"  consensus_rev_est_bn: {rev / 1e9:.4f}"  # already converted to billions
            if rev is not None else "  consensus_rev_est_bn: null"
        )
        lines.append(
            f"  revision_30d_pct: {rev30:.4f}  # signal: {_fmt_revision_signal(rev30)}"
            if rev30 is not None else "  revision_30d_pct: null"
        )
        lines.append(f"  target_period: {est.get('target_period') or 'null'}")
        lines.append(f"  provider_period_codes: {est.get('provider_period_codes') or {}}")
        lines.append(f"  period_aligned: {str(bool(est.get('period_aligned'))).lower()}")
        lines.append(f"  comparison_allowed: {str(bool(est.get('comparison_allowed'))).lower()}")
        lines.append(f"  alignment_status: {est.get('alignment_status') or 'UNVERIFIED'}")
    else:
        lines.append(f"  consensus_eps_est: null  # unavailable: {est_err}")
        lines.append("  consensus_rev_est_bn: null")
        lines.append("  revision_30d_pct: null")

    if price:
        event_allowed = bool(price.get("event_aligned") and price.get("comparison_allowed"))
        d1 = price.get("d1_pct") if event_allowed else None
        d5 = price.get("d5_pct") if event_allowed else None
        since = price.get("since_release_pct") if event_allowed else None
        lines.append(f"  d1_price_reaction_pct: {d1:.4f}" if d1 is not None else "  d1_price_reaction_pct: null")
        lines.append(f"  d5_price_reaction_pct: {d5:.4f}" if d5 is not None else "  d5_price_reaction_pct: null")
        lines.append(f"  since_release_price_reaction_pct: {since:.4f}" if since is not None else "  since_release_price_reaction_pct: null")
        lines.append(f"  event_date: {price.get('event_date') or 'null'}")
        lines.append(f"  event_kind: {price.get('event_kind') or 'unknown'}")
        lines.append(f"  event_timing: {price.get('event_timing') or 'unknown'}")
        lines.append(f"  event_aligned: {str(bool(price.get('event_aligned'))).lower()}")
        lines.append(f"  event_comparison_allowed: {str(bool(price.get('comparison_allowed'))).lower()}")
        lines.append(f"  price_alignment_status: {price.get('alignment_status') or 'UNVERIFIED'}")
    else:
        lines.append(f"  d1_price_reaction_pct: null  # unavailable: {price_err}")
        lines.append("  d5_price_reaction_pct: null")
        lines.append("  since_release_price_reaction_pct: null")

    try:
        content = "\n".join(lines)
        record = make_evidence_record(
            source="analyst",
            content=content,
            document_id=f"analyst:{ticker.upper()}:{latest_filing_date or 'current'}",
        as_of=(est or {}).get("as_of") or latest_filing_date,
            metadata={
                "ticker": ticker.upper(),
                "latest_filing_date": latest_filing_date,
                "target_period": target_period,
                "estimate_error": est_err,
                "price_error": price_err,
            },
        )
    except Exception as exc:
        return _error("get_analyst_expectations", request, exc)
    return evidence_envelope(tool="get_analyst_expectations", query=request, records=[record])


@tool
def search_news(query: str, ticker: str, days: int = 30) -> str:
    """Search for recent news about a company using Tavily.
    Use to find events after the most recent filing date.

    Args:
        query: The search query (e.g., "earnings", "acquisition")
        ticker: Stock ticker (e.g., "AAPL") — used to ground the search
        days: Number of days back to search (default: 30)

    Returns:
        An evidence.v1 JSON envelope (OK, EMPTY, or ERROR). Copy a supporting
        record's `ref` exactly when citing news."""
    request = {"query": query, "ticker": ticker.upper(), "days": days}
    # Check cache first (keyed by original args, 1-hour TTL)
    cached = news_cache.get(ticker, query, days)
    if cached is not None:
        if parse_evidence_envelope(cached):
            return cached
        # Legacy cache payloads have no immutable locator or source-level
        # provenance.  Never repackage them as newly verified evidence.
        return _empty(
            "search_news",
            request,
            "Legacy cached news omitted because its provenance cannot be verified.",
        )

    try:
        # Look up company name from metrics
        metrics_rows = metrics_db.get_all_metrics(ticker)
        company_name = metrics_rows[0]["company_name"] if metrics_rows else ticker

        # Get the latest filing date for filtering
        latest_filing_date = metrics_rows[0]["filing_date"] if metrics_rows else None

        # Compose ticker-grounded query
        full_query = f"{ticker.upper()} {company_name} {query}"

        results = _get_tavily_client().search(
            query=full_query,
            max_results=5,
            search_depth="basic",
            days=days,
            topic="news"
        ).get("results", [])
    except Exception as exc:
        return _error("search_news", request, exc)

    # Filter results by filing date
    if results and latest_filing_date:
        results = [r for r in results if r.get("published_date", "") >= latest_filing_date]

    if not results:
        result_str = _empty("search_news", request, "No recent news found.")
    else:
        try:
            records = []
            for r in results:
                published = str(r.get("published_date") or "unknown")
                url = str(r.get("url") or "")
                title = str(r.get("title") or "Untitled")
                content = f"Source: news | {published}\nTitle: {title}\nURL: {url}\n{r.get('content', '')}"
                records.append(make_evidence_record(
                    source="news",
                    content=content,
                    document_id=url or f"news:{ticker.upper()}:{published}:{title}",
                    source_url=url or None,
                    as_of=published if published != "unknown" else None,
                    metadata={
                        "ticker": ticker.upper(),
                        "title": title,
                        "published_date": published,
                    },
                ))
        except Exception as exc:
            return _error("search_news", request, exc)
        result_str = evidence_envelope(tool="search_news", query=request, records=records)

    news_cache.put(ticker, query, days, result_str)
    return result_str