feat(agent,interface): expose SEC filings through agent capabilities and stock page
Browse filesWires the filings data layer into user-facing surfaces:
- `sec_filings`, `sec_filing_document`, `sec_filing_section` agent
capabilities with workflow-shaped tool descriptions.
- Stock-page filings routes + payloads and a frontend SecFilings /
FilingMarkdown component for rendering 10-K/10-Q bodies.
- Integration tests for the capabilities, the service, and the stock
filings API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs/interface.md +49 -3
- src/TerraFin/agent/runtime.py +193 -0
- src/TerraFin/agent/service.py +368 -25
- src/TerraFin/agent/tool_contracts.py +92 -0
- src/TerraFin/agent/tools.py +191 -3
- src/TerraFin/configuration.py +1 -0
- src/TerraFin/interface/frontend/src/stock/StockPage.tsx +22 -0
- src/TerraFin/interface/frontend/src/stock/components/FilingMarkdown.test.helper.mjs +115 -0
- src/TerraFin/interface/frontend/src/stock/components/FilingMarkdown.tsx +196 -0
- src/TerraFin/interface/frontend/src/stock/components/SecFilings.tsx +469 -0
- src/TerraFin/interface/frontend/src/stock/useStockData.ts +93 -1
- src/TerraFin/interface/stock/data_routes.py +75 -0
- src/TerraFin/interface/stock/payloads.py +161 -0
- tests/agent/fixtures/aapl_10q_fy25q2_sample.md +75 -0
- tests/agent/fixtures/nvda_10q_fy26q3_sample.md +90 -0
- tests/agent/fixtures/tsla_10q_2025q2_sample.md +91 -0
- tests/agent/test_runtime.py +51 -0
- tests/agent/test_sec_capabilities.py +161 -0
- tests/agent/test_service.py +230 -2
- tests/agent/test_tools.py +124 -0
- tests/interface/test_stock_filings_api.py +146 -0
docs/interface.md
CHANGED
|
@@ -293,9 +293,10 @@ routes.
|
|
| 293 |
Source: `src/TerraFin/interface/stock/`
|
| 294 |
|
| 295 |
Stock Analysis combines a chart-first page route with a small API family for
|
| 296 |
-
company profile, earnings history, financials, and search routing.
|
| 297 |
-
itself uses the shared TerraFin chart session and progressive
|
| 298 |
-
history loading described in
|
|
|
|
| 299 |
|
| 300 |
### Page routes
|
| 301 |
|
|
@@ -311,8 +312,53 @@ history loading described in [chart-architecture.md](./chart-architecture.md).
|
|
| 311 |
| `GET` | `/stock/api/company-info` | Company profile and price summary (`?ticker=`) |
|
| 312 |
| `GET` | `/stock/api/earnings` | Earnings history (`?ticker=`) |
|
| 313 |
| `GET` | `/stock/api/financials` | Financial statements (`?ticker=`, `statement=`, `period=`) |
|
|
|
|
|
|
|
| 314 |
| `GET` | `/resolve-ticker` | Resolve free-form search into `/stock/...` or `/market-insights?...` |
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
---
|
| 317 |
|
| 318 |
## Watchlist
|
|
|
|
| 293 |
Source: `src/TerraFin/interface/stock/`
|
| 294 |
|
| 295 |
Stock Analysis combines a chart-first page route with a small API family for
|
| 296 |
+
company profile, earnings history, financials, SEC filings, and search routing.
|
| 297 |
+
The page itself uses the shared TerraFin chart session and progressive
|
| 298 |
+
`3Y -> full` history loading described in
|
| 299 |
+
[chart-architecture.md](./chart-architecture.md).
|
| 300 |
|
| 301 |
### Page routes
|
| 302 |
|
|
|
|
| 312 |
| `GET` | `/stock/api/company-info` | Company profile and price summary (`?ticker=`) |
|
| 313 |
| `GET` | `/stock/api/earnings` | Earnings history (`?ticker=`) |
|
| 314 |
| `GET` | `/stock/api/financials` | Financial statements (`?ticker=`, `statement=`, `period=`) |
|
| 315 |
+
| `GET` | `/stock/api/filings` | Recent 10-K / 10-Q / 8-K list with EDGAR URLs (`?ticker=`, `limit=`) |
|
| 316 |
+
| `GET` | `/stock/api/filing-document` | Parsed markdown + TOC for one filing (`?ticker=`, `accession=`, `primaryDocument=`, `form=`, `includeImages=`) |
|
| 317 |
| `GET` | `/resolve-ticker` | Resolve free-form search into `/stock/...` or `/market-insights?...` |
|
| 318 |
|
| 319 |
+
### SEC Filings panel
|
| 320 |
+
|
| 321 |
+
The `/stock/{ticker}` page includes a **SEC Filings** card for every US-listed
|
| 322 |
+
issuer. The card is hidden automatically for tickers without an SEC CIK (e.g.
|
| 323 |
+
KOSPI / TSE / HKEX issuers) so non-US pages stay uncluttered.
|
| 324 |
+
|
| 325 |
+
For supported tickers the card surfaces:
|
| 326 |
+
|
| 327 |
+
- a form dropdown derived from `df.form.unique()` (covers 10-K, 10-Q,
|
| 328 |
+
amendments, 8-K, 20-F, 40-F, etc.);
|
| 329 |
+
- a chronological filing list with a **View on EDGAR** link per row pointing
|
| 330 |
+
at the SEC inline-XBRL viewer (`/ix?doc=/Archives/...`);
|
| 331 |
+
- a reader that opens inline below the list, with:
|
| 332 |
+
- a two-level accordion preserving Part I / Part II as outer collapsibles
|
| 333 |
+
and Items (Item 1, Item 2 MD&A, …) as nested inner collapsibles;
|
| 334 |
+
- a compact custom markdown renderer that handles our `parse_sec_filing`
|
| 335 |
+
output (`##`/`###` headings, paragraphs, GFM pipe tables, blockquote
|
| 336 |
+
fallbacks, inline-image placeholders) without pulling in a general
|
| 337 |
+
markdown dep;
|
| 338 |
+
- a "View source on EDGAR" pill in the reader header.
|
| 339 |
+
|
| 340 |
+
The parsed markdown is cached for 30 days via the shared `sec_filings`
|
| 341 |
+
CacheManager namespace (see [caching.md](./caching.md)), so reopening a filing
|
| 342 |
+
is free across sessions. See [data-layer.md](./data-layer.md) for the
|
| 343 |
+
underlying `parse_sec_filing` / `build_toc` / `get_sec_data` helpers.
|
| 344 |
+
|
| 345 |
+
### Agent integration
|
| 346 |
+
|
| 347 |
+
When the user opens a filing, the panel publishes the currently-focused
|
| 348 |
+
section to the agent side-panel via `publishAgentViewContext`. The `selection`
|
| 349 |
+
carries `ticker`, `form`, `accession`, `primaryDocument`, `sectionSlug`,
|
| 350 |
+
`sectionTitle`, a bounded `sectionExcerpt` (≤ 4 KB), and EDGAR URLs. The
|
| 351 |
+
hosted agent's `current_view_context` tool reads this payload, and the agent
|
| 352 |
+
can call `sec_filings`, `sec_filing_document`, or `sec_filing_section` to
|
| 353 |
+
fetch the full body when the excerpt is not enough (e.g. "summarize their
|
| 354 |
+
business" on a 10-Q will trigger a cross-filing pivot to the most recent
|
| 355 |
+
10-K's Item 1. Business). See [agent/usage.md](./agent/usage.md#10-k--10-q-research).
|
| 356 |
+
|
| 357 |
+
For the full end-to-end view-context pipeline (how `publishAgentViewContext`
|
| 358 |
+
reaches the agent, how sessionStorage routes identity, why the session link
|
| 359 |
+
sometimes goes stale, and how to debug when the agent "doesn't know what I'm
|
| 360 |
+
looking at"), see [agent/view-context.md](./agent/view-context.md).
|
| 361 |
+
|
| 362 |
---
|
| 363 |
|
| 364 |
## Watchlist
|
src/TerraFin/agent/runtime.py
CHANGED
|
@@ -601,6 +601,89 @@ def build_default_capability_registry(
|
|
| 601 |
handler=resolved_service.calendar_events,
|
| 602 |
backgroundable=True,
|
| 603 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
TerraFinCapability(
|
| 605 |
name="open_chart",
|
| 606 |
description="Create or update a TerraFin chart session and return a chart artifact.",
|
|
@@ -630,6 +713,116 @@ def build_default_capability_registry(
|
|
| 630 |
focus_extractor=_focus_from_input_keys("ticker"),
|
| 631 |
backgroundable=True,
|
| 632 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
]
|
| 634 |
)
|
| 635 |
return registry
|
|
|
|
| 601 |
handler=resolved_service.calendar_events,
|
| 602 |
backgroundable=True,
|
| 603 |
),
|
| 604 |
+
# ----- Dashboard widget-parity capabilities -----
|
| 605 |
+
# Each of these mirrors a standalone widget the user sees on the
|
| 606 |
+
# dashboard / market-insights / stock-analysis pages. Without them
|
| 607 |
+
# the user would be surprised the agent can't comment on something
|
| 608 |
+
# they're staring at. Payloads pass through verbatim from the
|
| 609 |
+
# `build_*_payload` / `get_*` route helpers so the two views never
|
| 610 |
+
# diverge (DA audit: High-3/4/5, Med-6).
|
| 611 |
+
TerraFinCapability(
|
| 612 |
+
name="fear_greed",
|
| 613 |
+
description=(
|
| 614 |
+
"Fetch the CNN Fear & Greed index — same data as the "
|
| 615 |
+
"`/dashboard/api/fear-greed` widget. Returns score, rating, "
|
| 616 |
+
"previous close, and 1W/1M history."
|
| 617 |
+
),
|
| 618 |
+
handler=resolved_service.fear_greed,
|
| 619 |
+
),
|
| 620 |
+
TerraFinCapability(
|
| 621 |
+
name="sp500_dcf",
|
| 622 |
+
description=(
|
| 623 |
+
"Fetch the full S&P 500 DCF valuation — same shape as "
|
| 624 |
+
"`/market-insights/api/dcf/sp500`. Includes scenarios, "
|
| 625 |
+
"sensitivity matrix, methods, rateCurve, dataQuality."
|
| 626 |
+
),
|
| 627 |
+
handler=resolved_service.sp500_dcf,
|
| 628 |
+
backgroundable=True,
|
| 629 |
+
),
|
| 630 |
+
TerraFinCapability(
|
| 631 |
+
name="beta_estimate",
|
| 632 |
+
description=(
|
| 633 |
+
"Fetch a 5-year monthly beta estimate with adjusted beta, "
|
| 634 |
+
"R², and benchmark — same shape as `/stock/api/beta-estimate`. "
|
| 635 |
+
"Use this when you need the statistical quality of the beta; "
|
| 636 |
+
"`company_info` only surfaces a bare beta string."
|
| 637 |
+
),
|
| 638 |
+
handler=resolved_service.beta_estimate,
|
| 639 |
+
focus_extractor=_focus_from_input_keys("ticker"),
|
| 640 |
+
backgroundable=True,
|
| 641 |
+
),
|
| 642 |
+
TerraFinCapability(
|
| 643 |
+
name="top_companies",
|
| 644 |
+
description=(
|
| 645 |
+
"Fetch the market-insights top-companies list — same data as "
|
| 646 |
+
"`/market-insights/api/top-companies`."
|
| 647 |
+
),
|
| 648 |
+
handler=resolved_service.top_companies,
|
| 649 |
+
),
|
| 650 |
+
TerraFinCapability(
|
| 651 |
+
name="market_regime",
|
| 652 |
+
description=(
|
| 653 |
+
"Fetch the market regime summary — same data as "
|
| 654 |
+
"`/market-insights/api/regime`. Returns a short summary, "
|
| 655 |
+
"confidence, and bulleted signals."
|
| 656 |
+
),
|
| 657 |
+
handler=resolved_service.market_regime,
|
| 658 |
+
),
|
| 659 |
+
TerraFinCapability(
|
| 660 |
+
name="trailing_forward_pe",
|
| 661 |
+
description=(
|
| 662 |
+
"Fetch the trailing vs. forward P/E spread — same data as "
|
| 663 |
+
"`/dashboard/api/trailing-forward-pe-spread`."
|
| 664 |
+
),
|
| 665 |
+
handler=resolved_service.trailing_forward_pe,
|
| 666 |
+
backgroundable=True,
|
| 667 |
+
),
|
| 668 |
+
TerraFinCapability(
|
| 669 |
+
name="market_breadth",
|
| 670 |
+
description=(
|
| 671 |
+
"Fetch standalone market-breadth metrics — same data as the "
|
| 672 |
+
"MarketBreadthCard widget. Was previously bundled inside "
|
| 673 |
+
"`market_snapshot`; use this capability when the question is "
|
| 674 |
+
"about whole-market state rather than a single ticker."
|
| 675 |
+
),
|
| 676 |
+
handler=resolved_service.market_breadth,
|
| 677 |
+
),
|
| 678 |
+
TerraFinCapability(
|
| 679 |
+
name="watchlist",
|
| 680 |
+
description=(
|
| 681 |
+
"Fetch the user's current watchlist — same data as the "
|
| 682 |
+
"WatchlistSection widget. Standalone now; was previously "
|
| 683 |
+
"bundled inside `market_snapshot`."
|
| 684 |
+
),
|
| 685 |
+
handler=resolved_service.watchlist,
|
| 686 |
+
),
|
| 687 |
TerraFinCapability(
|
| 688 |
name="open_chart",
|
| 689 |
description="Create or update a TerraFin chart session and return a chart artifact.",
|
|
|
|
| 713 |
focus_extractor=_focus_from_input_keys("ticker"),
|
| 714 |
backgroundable=True,
|
| 715 |
),
|
| 716 |
+
TerraFinCapability(
|
| 717 |
+
name="sec_filings",
|
| 718 |
+
description=(
|
| 719 |
+
"List recent 10-K / 10-Q / 8-K filings for a ticker with SEC EDGAR links. "
|
| 720 |
+
"Call this ONCE when you need to pivot to a filing not currently in view. The "
|
| 721 |
+
"response's `latestByForm` dict is the direct lookup: "
|
| 722 |
+
"`latestByForm['10-K'].accession` / `.primaryDocument` give you everything "
|
| 723 |
+
"`sec_filing_section` needs. Do NOT scan the flat `filings` array — it's "
|
| 724 |
+
"chronological, so 8-Ks cluster at the top and the 10-K/10-Q you want may be in "
|
| 725 |
+
"position 5+.\n"
|
| 726 |
+
"If a filing is already shown in the user's view context, DON'T call this tool at "
|
| 727 |
+
"all — the accession and primaryDocument live in `current_view_context.selection`.\n"
|
| 728 |
+
"OUTPUT FORMAT — pick by question type:\n"
|
| 729 |
+
"• Quantitative filing analysis ('analyze the 10-Q', 'what do the numbers say'): "
|
| 730 |
+
"(1) lead with a '## TL;DR' of 3-5 bullets where each bullet is a punchline number "
|
| 731 |
+
"or concrete insight — no filler adjectives; "
|
| 732 |
+
"(2) follow with a compact '## Key numbers' table (≤6 rows) comparing current vs "
|
| 733 |
+
"prior-year period, showing only metrics that move the thesis; tables MUST include "
|
| 734 |
+
"the `| --- |` header-separator row after the header row; "
|
| 735 |
+
"(3) narrate with named subsections ('### The X story', '### The Y anomaly') of "
|
| 736 |
+
"≤3 sentences each, **bolding** one driver number per sentence maximum; "
|
| 737 |
+
"(4) close with '## Not disclosed in this filing' as a short bulleted list.\n"
|
| 738 |
+
"• Qualitative/descriptive question ('what is their business', 'how do they make "
|
| 739 |
+
"money', 'what are the risks'): 2-4 short paragraphs grounded in the full section "
|
| 740 |
+
"body (you MUST have called sec_filing_section first — don't answer off the 4 KB "
|
| 741 |
+
"excerpt). Lead with the single most important sentence, then add specifics: "
|
| 742 |
+
"products/segments, go-to-market, differentiation, any concrete numbers the "
|
| 743 |
+
"filing discloses. Skip the TL;DR bullets and Key-numbers table — they're for "
|
| 744 |
+
"quantitative questions, not descriptive ones.\n"
|
| 745 |
+
"EDITORIAL DISCIPLINE (strict — reviewers will reject otherwise): "
|
| 746 |
+
"(a) no adjectives not grounded in the filing text — avoid 'notable', 'heavy lifting', "
|
| 747 |
+
"'nearly wiped', 'pristine'; if you flip management's spin, cite the arithmetic that "
|
| 748 |
+
"supports the flip (and report the net number management disclosed); "
|
| 749 |
+
"(b) numbers in the TL;DR MUST match the Key-numbers table exactly — no '+12%' in one "
|
| 750 |
+
"and '+11.6%' in the other for the same metric; "
|
| 751 |
+
"(c) when the fixture provides both QoQ and YoY deltas (or both Q and YTD), report "
|
| 752 |
+
"both — a QoQ drop can hide YoY expansion and vice versa; "
|
| 753 |
+
"(d) scan every statement for line items moving >50% YoY and surface them — a "
|
| 754 |
+
"13x jump in a small bucket or a credits line halving is exactly what skimmers want; "
|
| 755 |
+
"(e) if MD&A rounding or direction contradicts what you compute from the statements, "
|
| 756 |
+
"report BOTH and prefer the statement-derived number, flagging the discrepancy."
|
| 757 |
+
),
|
| 758 |
+
handler=resolved_service.sec_filings,
|
| 759 |
+
focus_extractor=_focus_from_input_keys("ticker"),
|
| 760 |
+
),
|
| 761 |
+
TerraFinCapability(
|
| 762 |
+
name="sec_filing_document",
|
| 763 |
+
description=(
|
| 764 |
+
"Fetch the table of contents (section titles, slugs, and sizes) for a specific "
|
| 765 |
+
"10-K, 10-Q, or other SEC filing. Returns structure only — use `sec_filing_section` "
|
| 766 |
+
"to pull the actual prose. Requires the accession and primaryDocument fields from "
|
| 767 |
+
"`sec_filings` or the current view context.\n"
|
| 768 |
+
"Analyst discovery protocol — do NOT assume fixed item numbers across different "
|
| 769 |
+
"forms. Instead, scan the TOC for titles containing these keywords:\n"
|
| 770 |
+
"• Financial Data: 'Financial Statements', 'Notes', 'Consolidated Statements'.\n"
|
| 771 |
+
"• Strategy & Operations: 'Business', 'Management's Discussion', 'MD&A'.\n"
|
| 772 |
+
"• Risk & Legal: 'Risk Factors', 'Legal Proceedings', 'Controls'.\n"
|
| 773 |
+
"Plan to fetch the specific sections that contain the evidence needed for your "
|
| 774 |
+
"answer rather than guessing from summaries."
|
| 775 |
+
),
|
| 776 |
+
handler=resolved_service.sec_filing_document,
|
| 777 |
+
focus_extractor=_focus_from_input_keys("ticker"),
|
| 778 |
+
backgroundable=True,
|
| 779 |
+
),
|
| 780 |
+
TerraFinCapability(
|
| 781 |
+
name="sec_filing_section",
|
| 782 |
+
description=(
|
| 783 |
+
"Fetch the verbatim markdown body of a single filing section by slug. "
|
| 784 |
+
"The slug MUST come verbatim from a prior `sec_filing_document` call's TOC — "
|
| 785 |
+
"NEVER guess slug names from SEC filing conventions (the parser does not always "
|
| 786 |
+
"match convention; trust the actual TOC, not your prior knowledge).\n"
|
| 787 |
+
"\n"
|
| 788 |
+
"REQUIRED WORKFLOW (follow in order):\n"
|
| 789 |
+
"1. Call `sec_filing_document(ticker, accession, primaryDocument, form)` first to "
|
| 790 |
+
"obtain the TOC. Every entry has `{slug, text, charCount}`.\n"
|
| 791 |
+
"2. Pick the slug whose `text` matches what the user is asking about. If no text "
|
| 792 |
+
"matches cleanly (e.g. user asked about 'earnings' but there is no Item labelled "
|
| 793 |
+
"'Financial Statements'), use `charCount` as a size signal — the LARGEST section "
|
| 794 |
+
"in the relevant Part usually contains the content. 10-K MD&A and Financial "
|
| 795 |
+
"Statements often appear as a single very large section when the parser misses the "
|
| 796 |
+
"Item 7 / Item 8 split; a 200 KB section body in Part II is almost certainly "
|
| 797 |
+
"financial reporting regardless of what its heading says.\n"
|
| 798 |
+
"3. Pass that exact slug string to this tool. Do not reformat it, translate it, or "
|
| 799 |
+
"guess a canonical form.\n"
|
| 800 |
+
"\n"
|
| 801 |
+
"IF THIS TOOL RETURNS 'section not found': do NOT tell the user the section "
|
| 802 |
+
"doesn't exist. The error response includes the full list of available slugs with "
|
| 803 |
+
"their sizes. Pick one from that list (prefer the largest one in the relevant Part "
|
| 804 |
+
"if no name matches), and retry this tool immediately. Only after a second real "
|
| 805 |
+
"failure should you report inability to find the content.\n"
|
| 806 |
+
"\n"
|
| 807 |
+
"OUTPUT PROPERTIES:\n"
|
| 808 |
+
"- Returns raw, un-truncated markdown including tables. Use the tables to "
|
| 809 |
+
"recompute margins, growth rates, and capital allocation signals directly from "
|
| 810 |
+
"source data — do not fall back to the `financials` summary tool.\n"
|
| 811 |
+
"- If the user is viewing a filing, the `sectionExcerpt` in their view-context is "
|
| 812 |
+
"only ~4 KB. Substantive questions (strategy, full financials, segment detail) "
|
| 813 |
+
"REQUIRE this tool to fetch the full section body (often 100 KB+).\n"
|
| 814 |
+
"\n"
|
| 815 |
+
"VERBATIM CITATION RULE:\n"
|
| 816 |
+
"When you quote risk factors, MD&A language, forward-looking statements, or "
|
| 817 |
+
"legal commitments from the returned body, copy the exact wording inside "
|
| 818 |
+
"quotation marks and name the section. Do NOT paraphrase — users need to be "
|
| 819 |
+
"able to verify what the filing actually says, not what you think it says. "
|
| 820 |
+
"Paraphrasing into friendlier English in safety-sensitive sections is a bug."
|
| 821 |
+
),
|
| 822 |
+
handler=resolved_service.sec_filing_section,
|
| 823 |
+
focus_extractor=_focus_from_input_keys("ticker"),
|
| 824 |
+
backgroundable=True,
|
| 825 |
+
),
|
| 826 |
]
|
| 827 |
)
|
| 828 |
return registry
|
src/TerraFin/agent/service.py
CHANGED
|
@@ -7,6 +7,7 @@ from TerraFin.analytics.analysis.fundamental.dcf import (
|
|
| 7 |
build_stock_dcf_payload,
|
| 8 |
build_stock_reverse_dcf_payload,
|
| 9 |
)
|
|
|
|
| 10 |
from TerraFin.analytics.analysis.fundamental.screen import run_fundamental_screen
|
| 11 |
from TerraFin.analytics.analysis.risk.profile import run_risk_profile
|
| 12 |
from TerraFin.analytics.analysis.risk.returns import extract_close_series
|
|
@@ -33,9 +34,12 @@ from TerraFin.interface.market_insights.payloads import (
|
|
| 33 |
resolve_macro_type,
|
| 34 |
)
|
| 35 |
from TerraFin.interface.private_data_service import get_private_data_service
|
|
|
|
| 36 |
from TerraFin.interface.stock.payloads import (
|
| 37 |
build_company_info_payload,
|
| 38 |
build_earnings_payload,
|
|
|
|
|
|
|
| 39 |
build_financial_statement_payload,
|
| 40 |
resolve_ticker_query,
|
| 41 |
)
|
|
@@ -424,10 +428,19 @@ class TerraFinAgentService:
|
|
| 424 |
}
|
| 425 |
|
| 426 |
def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
payload = self._market_series(name, depth=depth, view=view)
|
| 428 |
series = payload["series"]
|
| 429 |
indicator_results, _ = _compute_indicator_results(series, ["rsi", "macd", "bb"])
|
| 430 |
-
private_service = get_private_data_service()
|
| 431 |
return {
|
| 432 |
"ticker": payload["name"],
|
| 433 |
"price_action": _price_action(series),
|
|
@@ -436,8 +449,6 @@ class TerraFinAgentService:
|
|
| 436 |
"macd_signal": indicator_results.get("macd", {}).get("values", {}).get("signal"),
|
| 437 |
"bb_position": indicator_results.get("bb", {}).get("values", {}).get("position"),
|
| 438 |
},
|
| 439 |
-
"market_breadth": private_service.get_market_breadth(),
|
| 440 |
-
"watchlist": get_watchlist_service().get_watchlist_snapshot(),
|
| 441 |
"processing": payload["processing"],
|
| 442 |
}
|
| 443 |
|
|
@@ -512,13 +523,139 @@ class TerraFinAgentService:
|
|
| 512 |
)
|
| 513 |
return payload
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
def portfolio(self, guru: str) -> dict[str, Any]:
|
| 516 |
output = get_portfolio_data(guru)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
return {
|
| 518 |
"guru": guru,
|
| 519 |
"info": dict(output.info),
|
| 520 |
-
"holdings":
|
| 521 |
-
"
|
|
|
|
| 522 |
"processing": _full_processing(
|
| 523 |
requested_depth="full",
|
| 524 |
source_version="portfolio",
|
|
@@ -572,11 +709,64 @@ class TerraFinAgentService:
|
|
| 572 |
),
|
| 573 |
}
|
| 574 |
|
| 575 |
-
def macro_focus(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
resolved_name = canonical_macro_name(name)
|
| 577 |
indicator_type = resolve_macro_type(resolved_name)
|
| 578 |
if indicator_type is None:
|
| 579 |
raise LookupError(f"Unknown macro instrument: '{resolved_name}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
payload = self._market_series(resolved_name, depth=depth, view=view)
|
| 581 |
frame = payload["frame"]
|
| 582 |
info = build_macro_info_payload(
|
|
@@ -709,32 +899,27 @@ class TerraFinAgentService:
|
|
| 709 |
if intrinsic_value and current_price and current_price > 0:
|
| 710 |
margin_of_safety = round((intrinsic_value - current_price) / current_price * 100, 2)
|
| 711 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
return {
|
| 713 |
"ticker": normalized,
|
| 714 |
-
"dcf":
|
| 715 |
-
|
| 716 |
-
"intrinsic_value": dcf.get("currentIntrinsicValue"),
|
| 717 |
-
"upside_pct": dcf.get("upsidePct"),
|
| 718 |
-
"assumptions": dcf.get("assumptions"),
|
| 719 |
-
"warnings": dcf.get("warnings", []),
|
| 720 |
-
},
|
| 721 |
-
"reverse_dcf": {
|
| 722 |
-
"status": reverse_dcf.get("status"),
|
| 723 |
-
"implied_growth_pct": reverse_dcf.get("impliedGrowthPct"),
|
| 724 |
-
"model_price": reverse_dcf.get("modelPrice"),
|
| 725 |
-
"warnings": reverse_dcf.get("warnings", []),
|
| 726 |
-
},
|
| 727 |
"relative": {
|
| 728 |
-
"
|
| 729 |
-
"
|
| 730 |
-
"
|
| 731 |
round(current_price / bvps, 2)
|
| 732 |
if current_price and bvps and bvps > 0 else None
|
| 733 |
),
|
| 734 |
},
|
| 735 |
-
"
|
| 736 |
-
"
|
| 737 |
-
"
|
| 738 |
"processing": _full_processing(
|
| 739 |
requested_depth="full",
|
| 740 |
source_version="valuation",
|
|
@@ -742,3 +927,161 @@ class TerraFinAgentService:
|
|
| 742 |
frame=None,
|
| 743 |
),
|
| 744 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
build_stock_dcf_payload,
|
| 8 |
build_stock_reverse_dcf_payload,
|
| 9 |
)
|
| 10 |
+
from TerraFin.analytics.analysis.fundamental.dcf.presenters import build_sp500_dcf_payload
|
| 11 |
from TerraFin.analytics.analysis.fundamental.screen import run_fundamental_screen
|
| 12 |
from TerraFin.analytics.analysis.risk.profile import run_risk_profile
|
| 13 |
from TerraFin.analytics.analysis.risk.returns import extract_close_series
|
|
|
|
| 34 |
resolve_macro_type,
|
| 35 |
)
|
| 36 |
from TerraFin.interface.private_data_service import get_private_data_service
|
| 37 |
+
from TerraFin.interface.stock.data_routes import build_beta_estimate_payload
|
| 38 |
from TerraFin.interface.stock.payloads import (
|
| 39 |
build_company_info_payload,
|
| 40 |
build_earnings_payload,
|
| 41 |
+
build_filing_document_payload,
|
| 42 |
+
build_filings_list_payload,
|
| 43 |
build_financial_statement_payload,
|
| 44 |
resolve_ticker_query,
|
| 45 |
)
|
|
|
|
| 428 |
}
|
| 429 |
|
| 430 |
def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, Any]:
|
| 431 |
+
"""Per-ticker price action + indicators only.
|
| 432 |
+
|
| 433 |
+
Previously bundled market-wide `market_breadth` and `watchlist`
|
| 434 |
+
inside this response, which mixed a per-ticker view with
|
| 435 |
+
whole-market state — the agent would reference breadth numbers
|
| 436 |
+
alongside a ticker snapshot and risk diverging from the
|
| 437 |
+
standalone MarketBreadthCard widget if the widget refreshed on
|
| 438 |
+
its own. Use the `market_breadth` and `watchlist` capabilities
|
| 439 |
+
for those (matching the standalone widgets).
|
| 440 |
+
"""
|
| 441 |
payload = self._market_series(name, depth=depth, view=view)
|
| 442 |
series = payload["series"]
|
| 443 |
indicator_results, _ = _compute_indicator_results(series, ["rsi", "macd", "bb"])
|
|
|
|
| 444 |
return {
|
| 445 |
"ticker": payload["name"],
|
| 446 |
"price_action": _price_action(series),
|
|
|
|
| 449 |
"macd_signal": indicator_results.get("macd", {}).get("values", {}).get("signal"),
|
| 450 |
"bb_position": indicator_results.get("bb", {}).get("values", {}).get("position"),
|
| 451 |
},
|
|
|
|
|
|
|
| 452 |
"processing": payload["processing"],
|
| 453 |
}
|
| 454 |
|
|
|
|
| 523 |
)
|
| 524 |
return payload
|
| 525 |
|
| 526 |
+
def sec_filings(self, ticker: str) -> dict[str, Any]:
|
| 527 |
+
"""List recent 10-K / 10-Q / 8-K filings for a ticker with EDGAR links."""
|
| 528 |
+
payload = build_filings_list_payload(ticker)
|
| 529 |
+
return {
|
| 530 |
+
**payload,
|
| 531 |
+
"processing": _full_processing(
|
| 532 |
+
requested_depth="full",
|
| 533 |
+
source_version="sec-filings-list",
|
| 534 |
+
view=None,
|
| 535 |
+
frame=None,
|
| 536 |
+
),
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
def sec_filing_document(
|
| 540 |
+
self,
|
| 541 |
+
ticker: str,
|
| 542 |
+
accession: str,
|
| 543 |
+
primaryDocument: str,
|
| 544 |
+
*,
|
| 545 |
+
form: str = "10-Q",
|
| 546 |
+
) -> dict[str, Any]:
|
| 547 |
+
"""Fetch a filing's structured table of contents without the full markdown body.
|
| 548 |
+
|
| 549 |
+
Agents use this to decide which sections are worth reading before pulling
|
| 550 |
+
their prose via ``sec_filing_section`` — keeps the model's working
|
| 551 |
+
context small even for 60 KB+ filings.
|
| 552 |
+
"""
|
| 553 |
+
payload = build_filing_document_payload(ticker, accession, primaryDocument, form=form)
|
| 554 |
+
return {
|
| 555 |
+
"ticker": payload["ticker"],
|
| 556 |
+
"accession": payload["accession"],
|
| 557 |
+
"primaryDocument": payload["primaryDocument"],
|
| 558 |
+
"toc": payload["toc"],
|
| 559 |
+
"charCount": payload["charCount"],
|
| 560 |
+
"indexUrl": payload["indexUrl"],
|
| 561 |
+
"documentUrl": payload["documentUrl"],
|
| 562 |
+
"processing": _full_processing(
|
| 563 |
+
requested_depth="full",
|
| 564 |
+
source_version="sec-filing-document",
|
| 565 |
+
view=None,
|
| 566 |
+
frame=None,
|
| 567 |
+
),
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
def sec_filing_section(
|
| 571 |
+
self,
|
| 572 |
+
ticker: str,
|
| 573 |
+
accession: str,
|
| 574 |
+
primaryDocument: str,
|
| 575 |
+
sectionSlug: str,
|
| 576 |
+
*,
|
| 577 |
+
form: str = "10-Q",
|
| 578 |
+
) -> dict[str, Any]:
|
| 579 |
+
"""Fetch a single section body by slug from a filing.
|
| 580 |
+
|
| 581 |
+
Returns only the target section's markdown, keeping the agent's context
|
| 582 |
+
small for iterative section-by-section analysis.
|
| 583 |
+
"""
|
| 584 |
+
payload = build_filing_document_payload(ticker, accession, primaryDocument, form=form)
|
| 585 |
+
toc = payload["toc"]
|
| 586 |
+
try:
|
| 587 |
+
entry = next(e for e in toc if e["slug"] == sectionSlug)
|
| 588 |
+
except StopIteration as exc:
|
| 589 |
+
# Surface the FULL TOC with sizes so the agent has everything it
|
| 590 |
+
# needs to retry without another `sec_filing_document` round-trip.
|
| 591 |
+
# Sort suggestions by charCount descending — when the intended
|
| 592 |
+
# section wasn't caught by the parser (common for 10-K Item 7/8),
|
| 593 |
+
# the wanted content usually lives inside the largest neighbor.
|
| 594 |
+
sorted_entries = sorted(
|
| 595 |
+
({"slug": e["slug"], "text": e["text"], "charCount": e["charCount"]} for e in toc),
|
| 596 |
+
key=lambda e: e["charCount"],
|
| 597 |
+
reverse=True,
|
| 598 |
+
)
|
| 599 |
+
top_hint = ", ".join(
|
| 600 |
+
f"{e['slug']} ({e['charCount']:,} chars, '{e['text']}')"
|
| 601 |
+
for e in sorted_entries[:5]
|
| 602 |
+
)
|
| 603 |
+
raise LookupError(
|
| 604 |
+
f"Section '{sectionSlug}' not found. "
|
| 605 |
+
f"Do NOT report 'section doesn't exist' — retry this tool with one of the available "
|
| 606 |
+
f"slugs. The 5 largest sections in this filing are: {top_hint}. "
|
| 607 |
+
f"If the user asked about earnings/financials/MD&A and no slug matches those names "
|
| 608 |
+
f"directly, pick the LARGEST slug in Part II — 10-K parsers often leave MD&A and "
|
| 609 |
+
f"Financial Statements inside an oversized neighbor section. "
|
| 610 |
+
f"All {len(toc)} available slugs: "
|
| 611 |
+
+ ", ".join(e["slug"] for e in toc)
|
| 612 |
+
) from exc
|
| 613 |
+
|
| 614 |
+
# Upper bound = next raw TOC entry (by ascending lineIndex), so the body
|
| 615 |
+
# stops at the exact next heading in the markdown.
|
| 616 |
+
lines = payload["markdown"].split("\n")
|
| 617 |
+
later = [e for e in toc if e["lineIndex"] > entry["lineIndex"]]
|
| 618 |
+
end_line = later[0]["lineIndex"] if later else len(lines)
|
| 619 |
+
body = "\n".join(lines[entry["lineIndex"] + 1 : end_line]).strip()
|
| 620 |
+
|
| 621 |
+
return {
|
| 622 |
+
"ticker": payload["ticker"],
|
| 623 |
+
"accession": payload["accession"],
|
| 624 |
+
"sectionSlug": sectionSlug,
|
| 625 |
+
"sectionTitle": entry["text"],
|
| 626 |
+
"markdown": body,
|
| 627 |
+
"charCount": len(body),
|
| 628 |
+
"documentUrl": payload["documentUrl"],
|
| 629 |
+
"processing": _full_processing(
|
| 630 |
+
requested_depth="full",
|
| 631 |
+
source_version="sec-filing-section",
|
| 632 |
+
view=None,
|
| 633 |
+
frame=None,
|
| 634 |
+
),
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
def portfolio(self, guru: str) -> dict[str, Any]:
|
| 638 |
output = get_portfolio_data(guru)
|
| 639 |
+
# Mirror the route's `topHoldings` pre-computation so the agent and
|
| 640 |
+
# the UI's treemap/ranking use the same top 8. Without this, the
|
| 641 |
+
# agent would have to re-sort the full `holdings` list and could
|
| 642 |
+
# diverge on ties or sort-key interpretation (DA Med-9).
|
| 643 |
+
df = output.df
|
| 644 |
+
if not df.empty and {"Stock", "% of Portfolio", "Recent Activity", "Updated"}.issubset(df.columns):
|
| 645 |
+
top_holdings = (
|
| 646 |
+
df[["Stock", "% of Portfolio", "Recent Activity", "Updated"]]
|
| 647 |
+
.sort_values(by="% of Portfolio", ascending=False)
|
| 648 |
+
.head(8)
|
| 649 |
+
.to_dict(orient="records")
|
| 650 |
+
)
|
| 651 |
+
else:
|
| 652 |
+
top_holdings = []
|
| 653 |
return {
|
| 654 |
"guru": guru,
|
| 655 |
"info": dict(output.info),
|
| 656 |
+
"holdings": df.to_dict(orient="records"),
|
| 657 |
+
"topHoldings": top_holdings,
|
| 658 |
+
"count": len(df),
|
| 659 |
"processing": _full_processing(
|
| 660 |
requested_depth="full",
|
| 661 |
source_version="portfolio",
|
|
|
|
| 709 |
),
|
| 710 |
}
|
| 711 |
|
| 712 |
+
def macro_focus(
|
| 713 |
+
self,
|
| 714 |
+
name: str,
|
| 715 |
+
*,
|
| 716 |
+
depth: str = "auto",
|
| 717 |
+
view: str = "daily",
|
| 718 |
+
session_id: str | None = None,
|
| 719 |
+
) -> dict[str, Any]:
|
| 720 |
+
"""Macro summary + chart-ready series for a named instrument.
|
| 721 |
+
|
| 722 |
+
If `session_id` is supplied and the user has stored a named series
|
| 723 |
+
on that session (e.g. a custom range loaded into the macro chart
|
| 724 |
+
via the market-insights page), prefer that session-local frame —
|
| 725 |
+
matches `/market-insights/api/macro-info`'s behavior, where the
|
| 726 |
+
frontend sends `X-Session-ID` to avoid ignoring the user's chart
|
| 727 |
+
state. Without session-awareness the agent would answer questions
|
| 728 |
+
about a different time range than the user was staring at.
|
| 729 |
+
"""
|
| 730 |
resolved_name = canonical_macro_name(name)
|
| 731 |
indicator_type = resolve_macro_type(resolved_name)
|
| 732 |
if indicator_type is None:
|
| 733 |
raise LookupError(f"Unknown macro instrument: '{resolved_name}'")
|
| 734 |
+
|
| 735 |
+
session_frame = None
|
| 736 |
+
if session_id:
|
| 737 |
+
try:
|
| 738 |
+
from TerraFin.interface.chart.state import get_named_series
|
| 739 |
+
|
| 740 |
+
session_frame = get_named_series(session_id).get(resolved_name)
|
| 741 |
+
except Exception:
|
| 742 |
+
session_frame = None
|
| 743 |
+
|
| 744 |
+
if session_frame is not None:
|
| 745 |
+
# User has a custom macro series loaded in their session. Use it
|
| 746 |
+
# verbatim so agent's view matches what they're looking at.
|
| 747 |
+
session_frame.name = resolved_name
|
| 748 |
+
series_payload = _primary_series(session_frame, view=_normalize_view(view))
|
| 749 |
+
info = build_macro_info_payload(
|
| 750 |
+
resolved_name,
|
| 751 |
+
get_macro_description(resolved_name),
|
| 752 |
+
session_frame,
|
| 753 |
+
indicator_type=indicator_type,
|
| 754 |
+
)
|
| 755 |
+
processing = _full_processing(
|
| 756 |
+
requested_depth=_normalize_depth(depth),
|
| 757 |
+
source_version="macro-session",
|
| 758 |
+
view=_normalize_view(view),
|
| 759 |
+
frame=session_frame,
|
| 760 |
+
)
|
| 761 |
+
return {
|
| 762 |
+
"name": resolved_name,
|
| 763 |
+
"info": info,
|
| 764 |
+
"seriesType": series_payload.get("seriesType", "line"),
|
| 765 |
+
"count": len(series_payload.get("data", [])),
|
| 766 |
+
"data": _series_points(series_payload),
|
| 767 |
+
"processing": processing,
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
payload = self._market_series(resolved_name, depth=depth, view=view)
|
| 771 |
frame = payload["frame"]
|
| 772 |
info = build_macro_info_payload(
|
|
|
|
| 899 |
if intrinsic_value and current_price and current_price > 0:
|
| 900 |
margin_of_safety = round((intrinsic_value - current_price) / current_price * 100, 2)
|
| 901 |
|
| 902 |
+
# Pass the full route payloads through verbatim so the agent sees the
|
| 903 |
+
# exact same `DCFValuationResponse` / `ReverseDCFResponse` structure
|
| 904 |
+
# that renders in the frontend's DcfValuationPanel — scenarios,
|
| 905 |
+
# sensitivity matrix, methods, rateCurve, dataQuality, all of it.
|
| 906 |
+
# Previously this method cherry-picked 4 fields from each, which
|
| 907 |
+
# broke user↔agent view parity (audit: DA High-1, High-2).
|
| 908 |
return {
|
| 909 |
"ticker": normalized,
|
| 910 |
+
"dcf": dcf,
|
| 911 |
+
"reverseDcf": reverse_dcf,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
"relative": {
|
| 913 |
+
"trailingPE": trailing_pe,
|
| 914 |
+
"forwardPE": forward_pe,
|
| 915 |
+
"priceToBook": (
|
| 916 |
round(current_price / bvps, 2)
|
| 917 |
if current_price and bvps and bvps > 0 else None
|
| 918 |
),
|
| 919 |
},
|
| 920 |
+
"grahamNumber": graham_number,
|
| 921 |
+
"marginOfSafetyPct": margin_of_safety,
|
| 922 |
+
"currentPrice": current_price,
|
| 923 |
"processing": _full_processing(
|
| 924 |
requested_depth="full",
|
| 925 |
source_version="valuation",
|
|
|
|
| 927 |
frame=None,
|
| 928 |
),
|
| 929 |
}
|
| 930 |
+
|
| 931 |
+
# -----------------------------------------------------------------
|
| 932 |
+
# Capabilities that expose standalone widgets the dashboard and
|
| 933 |
+
# market-insights pages render. Before these existed, the agent
|
| 934 |
+
# could not answer questions about data the user was clearly
|
| 935 |
+
# looking at (Fear & Greed, S&P 500 DCF, beta R², top companies,
|
| 936 |
+
# regime summary, trailing-forward P/E). Every method here returns
|
| 937 |
+
# the route's payload verbatim (camelCase intact) so the agent
|
| 938 |
+
# sees the same fields the frontend renders.
|
| 939 |
+
# -----------------------------------------------------------------
|
| 940 |
+
|
| 941 |
+
def fear_greed(self) -> dict[str, Any]:
|
| 942 |
+
"""CNN Fear & Greed index — matches `/dashboard/api/fear-greed`."""
|
| 943 |
+
payload = dict(get_private_data_service().get_fear_greed_current())
|
| 944 |
+
payload["processing"] = _full_processing(
|
| 945 |
+
requested_depth="full",
|
| 946 |
+
source_version="fear-greed",
|
| 947 |
+
view=None,
|
| 948 |
+
frame=None,
|
| 949 |
+
)
|
| 950 |
+
return payload
|
| 951 |
+
|
| 952 |
+
def sp500_dcf(self) -> dict[str, Any]:
|
| 953 |
+
"""Full S&P 500 DCF — matches `/market-insights/api/dcf/sp500`.
|
| 954 |
+
|
| 955 |
+
Returns the same DCFValuationResponse shape (scenarios, sensitivity,
|
| 956 |
+
methods, rateCurve, dataQuality) the SP500 DCF panel renders.
|
| 957 |
+
"""
|
| 958 |
+
payload = dict(build_sp500_dcf_payload())
|
| 959 |
+
payload["processing"] = _full_processing(
|
| 960 |
+
requested_depth="full",
|
| 961 |
+
source_version="sp500-dcf",
|
| 962 |
+
view=None,
|
| 963 |
+
frame=None,
|
| 964 |
+
)
|
| 965 |
+
return payload
|
| 966 |
+
|
| 967 |
+
def beta_estimate(self, ticker: str) -> dict[str, Any]:
|
| 968 |
+
"""5-year monthly beta estimate — matches `/stock/api/beta-estimate`.
|
| 969 |
+
|
| 970 |
+
Returns beta, adjusted beta, R², observations, benchmark, warnings.
|
| 971 |
+
The `company_info` tool only surfaces a string `beta` field; use this
|
| 972 |
+
capability when you need the statistical quality of the estimate.
|
| 973 |
+
"""
|
| 974 |
+
payload = dict(build_beta_estimate_payload(ticker))
|
| 975 |
+
payload["processing"] = _full_processing(
|
| 976 |
+
requested_depth="full",
|
| 977 |
+
source_version="beta-estimate",
|
| 978 |
+
view=None,
|
| 979 |
+
frame=None,
|
| 980 |
+
)
|
| 981 |
+
return payload
|
| 982 |
+
|
| 983 |
+
def top_companies(self) -> dict[str, Any]:
|
| 984 |
+
"""Market-insights top-companies list — matches `/market-insights/api/top-companies`."""
|
| 985 |
+
try:
|
| 986 |
+
companies = get_private_data_service().get_top_companies()
|
| 987 |
+
except Exception:
|
| 988 |
+
companies = []
|
| 989 |
+
return {
|
| 990 |
+
"companies": companies,
|
| 991 |
+
"count": len(companies),
|
| 992 |
+
"processing": _full_processing(
|
| 993 |
+
requested_depth="full",
|
| 994 |
+
source_version="top-companies",
|
| 995 |
+
view=None,
|
| 996 |
+
frame=None,
|
| 997 |
+
),
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
def market_regime(self) -> dict[str, Any]:
|
| 1001 |
+
"""Market regime summary — matches `/market-insights/api/regime`.
|
| 1002 |
+
|
| 1003 |
+
The route currently returns a static placeholder; the agent sees the
|
| 1004 |
+
same placeholder so the two views never diverge. If the route is
|
| 1005 |
+
upgraded to a real regime model later, this capability will
|
| 1006 |
+
automatically reflect the change without code edits here.
|
| 1007 |
+
"""
|
| 1008 |
+
# Mirror the route's placeholder exactly — user↔agent parity trumps
|
| 1009 |
+
# "placeholder data should be obvious to callers". If the route is
|
| 1010 |
+
# updated to a real model, update both sides together.
|
| 1011 |
+
return {
|
| 1012 |
+
"summary": "Mixed regime with selective risk-taking and elevated event sensitivity.",
|
| 1013 |
+
"confidence": "low",
|
| 1014 |
+
"signals": [
|
| 1015 |
+
"Breadth is improving in pockets but still uneven.",
|
| 1016 |
+
"Macro event concentration this week can raise short-term volatility.",
|
| 1017 |
+
"Leadership remains concentrated in a handful of large-cap names.",
|
| 1018 |
+
],
|
| 1019 |
+
"processing": _full_processing(
|
| 1020 |
+
requested_depth="full",
|
| 1021 |
+
source_version="market-regime-placeholder",
|
| 1022 |
+
view=None,
|
| 1023 |
+
frame=None,
|
| 1024 |
+
),
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
def trailing_forward_pe(self) -> dict[str, Any]:
|
| 1028 |
+
"""Trailing minus forward P/E spread — matches `/dashboard/api/trailing-forward-pe-spread`."""
|
| 1029 |
+
private_service = get_private_data_service()
|
| 1030 |
+
payload = private_service.get_trailing_forward_pe() or {}
|
| 1031 |
+
summary = payload.get("summary", {}) if isinstance(payload, dict) else {}
|
| 1032 |
+
coverage = payload.get("coverage", {}) if isinstance(payload, dict) else {}
|
| 1033 |
+
history = payload.get("history", []) if isinstance(payload, dict) else []
|
| 1034 |
+
return {
|
| 1035 |
+
"date": str(payload.get("date", "")) if isinstance(payload, dict) else "",
|
| 1036 |
+
"description": (
|
| 1037 |
+
"Trailing P/E minus forward P/E, used as a rough proxy for how much "
|
| 1038 |
+
"future earnings expectations diverge from trailing earnings."
|
| 1039 |
+
),
|
| 1040 |
+
"latestValue": summary.get("trailing_forward_pe_spread"),
|
| 1041 |
+
"usableCount": coverage.get("usable"),
|
| 1042 |
+
"requestedCount": coverage.get("requested"),
|
| 1043 |
+
"history": list(history),
|
| 1044 |
+
"processing": _full_processing(
|
| 1045 |
+
requested_depth="full",
|
| 1046 |
+
source_version="trailing-forward-pe",
|
| 1047 |
+
view=None,
|
| 1048 |
+
frame=None,
|
| 1049 |
+
),
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
def market_breadth(self) -> dict[str, Any]:
|
| 1053 |
+
"""Market breadth metrics — matches `/dashboard/api/market-breadth`.
|
| 1054 |
+
|
| 1055 |
+
Was previously bundled inside `market_snapshot`, which mixed a
|
| 1056 |
+
per-ticker view with whole-market state. Now a standalone capability
|
| 1057 |
+
so agent and the MarketBreadthCard widget query the same data.
|
| 1058 |
+
"""
|
| 1059 |
+
metrics = get_private_data_service().get_market_breadth()
|
| 1060 |
+
return {
|
| 1061 |
+
"metrics": list(metrics),
|
| 1062 |
+
"processing": _full_processing(
|
| 1063 |
+
requested_depth="full",
|
| 1064 |
+
source_version="market-breadth",
|
| 1065 |
+
view=None,
|
| 1066 |
+
frame=None,
|
| 1067 |
+
),
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
def watchlist(self) -> dict[str, Any]:
|
| 1071 |
+
"""User's watchlist — matches the WatchlistSection dashboard widget.
|
| 1072 |
+
|
| 1073 |
+
Was previously bundled inside `market_snapshot`. Standalone here so
|
| 1074 |
+
agent and the widget query the same list without ticker-scoped
|
| 1075 |
+
confusion.
|
| 1076 |
+
"""
|
| 1077 |
+
snapshot = get_watchlist_service().get_watchlist_snapshot() or []
|
| 1078 |
+
return {
|
| 1079 |
+
"items": list(snapshot),
|
| 1080 |
+
"count": len(snapshot),
|
| 1081 |
+
"processing": _full_processing(
|
| 1082 |
+
requested_depth="full",
|
| 1083 |
+
source_version="watchlist",
|
| 1084 |
+
view=None,
|
| 1085 |
+
frame=None,
|
| 1086 |
+
),
|
| 1087 |
+
}
|
src/TerraFin/agent/tool_contracts.py
CHANGED
|
@@ -215,6 +215,98 @@ HOSTED_TOOL_CONTRACTS: dict[str, dict[str, Any]] = {
|
|
| 215 |
),
|
| 216 |
"response_model": "ValuationResponse",
|
| 217 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
}
|
| 219 |
|
| 220 |
|
|
|
|
| 215 |
),
|
| 216 |
"response_model": "ValuationResponse",
|
| 217 |
},
|
| 218 |
+
"sec_filings": {
|
| 219 |
+
"input_schema": _object_schema(
|
| 220 |
+
properties={"ticker": {"type": "string", "minLength": 1}},
|
| 221 |
+
required=["ticker"],
|
| 222 |
+
),
|
| 223 |
+
"response_model": "SecFilingsListResponse",
|
| 224 |
+
},
|
| 225 |
+
"sec_filing_document": {
|
| 226 |
+
"input_schema": _object_schema(
|
| 227 |
+
properties={
|
| 228 |
+
"ticker": {"type": "string", "minLength": 1},
|
| 229 |
+
"accession": {"type": "string", "minLength": 1},
|
| 230 |
+
"primaryDocument": {"type": "string", "minLength": 1},
|
| 231 |
+
"form": {"type": "string", "minLength": 1, "default": "10-Q"},
|
| 232 |
+
},
|
| 233 |
+
required=["ticker", "accession", "primaryDocument"],
|
| 234 |
+
),
|
| 235 |
+
"response_model": "SecFilingDocumentResponse",
|
| 236 |
+
},
|
| 237 |
+
"sec_filing_section": {
|
| 238 |
+
"input_schema": _object_schema(
|
| 239 |
+
properties={
|
| 240 |
+
"ticker": {"type": "string", "minLength": 1},
|
| 241 |
+
"accession": {"type": "string", "minLength": 1},
|
| 242 |
+
"primaryDocument": {"type": "string", "minLength": 1},
|
| 243 |
+
"sectionSlug": {"type": "string", "minLength": 1},
|
| 244 |
+
"form": {"type": "string", "minLength": 1, "default": "10-Q"},
|
| 245 |
+
},
|
| 246 |
+
required=["ticker", "accession", "primaryDocument", "sectionSlug"],
|
| 247 |
+
),
|
| 248 |
+
"response_model": "SecFilingSectionResponse",
|
| 249 |
+
},
|
| 250 |
+
"fear_greed": {
|
| 251 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 252 |
+
"response_model": "FearGreedResponse",
|
| 253 |
+
},
|
| 254 |
+
"sp500_dcf": {
|
| 255 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 256 |
+
"response_model": "DCFValuationResponse",
|
| 257 |
+
},
|
| 258 |
+
"beta_estimate": {
|
| 259 |
+
"input_schema": _object_schema(
|
| 260 |
+
properties={"ticker": {"type": "string", "minLength": 1}},
|
| 261 |
+
required=["ticker"],
|
| 262 |
+
),
|
| 263 |
+
"response_model": "BetaEstimateResponse",
|
| 264 |
+
},
|
| 265 |
+
"top_companies": {
|
| 266 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 267 |
+
"response_model": "TopCompaniesResponse",
|
| 268 |
+
},
|
| 269 |
+
"market_regime": {
|
| 270 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 271 |
+
"response_model": "MarketRegimeResponse",
|
| 272 |
+
},
|
| 273 |
+
"trailing_forward_pe": {
|
| 274 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 275 |
+
"response_model": "TrailingForwardPeSpreadResponse",
|
| 276 |
+
},
|
| 277 |
+
"market_breadth": {
|
| 278 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 279 |
+
"response_model": "MarketBreadthResponse",
|
| 280 |
+
},
|
| 281 |
+
"watchlist": {
|
| 282 |
+
"input_schema": _object_schema(properties={}, required=[]),
|
| 283 |
+
"response_model": "WatchlistResponse",
|
| 284 |
+
},
|
| 285 |
+
# Persona-consult tools. Each takes a single `question` arg (the
|
| 286 |
+
# orchestrator-scoped prompt sent to the persona subagent) and returns
|
| 287 |
+
# a `GuruResearchMemo`-shaped payload. See
|
| 288 |
+
# `docs/agent/architecture.md#orchestrator--persona-subagents`.
|
| 289 |
+
"consult_warren_buffett": {
|
| 290 |
+
"input_schema": _object_schema(
|
| 291 |
+
properties={"question": {"type": "string", "minLength": 1}},
|
| 292 |
+
required=["question"],
|
| 293 |
+
),
|
| 294 |
+
"response_model": "GuruResearchMemo",
|
| 295 |
+
},
|
| 296 |
+
"consult_howard_marks": {
|
| 297 |
+
"input_schema": _object_schema(
|
| 298 |
+
properties={"question": {"type": "string", "minLength": 1}},
|
| 299 |
+
required=["question"],
|
| 300 |
+
),
|
| 301 |
+
"response_model": "GuruResearchMemo",
|
| 302 |
+
},
|
| 303 |
+
"consult_stanley_druckenmiller": {
|
| 304 |
+
"input_schema": _object_schema(
|
| 305 |
+
properties={"question": {"type": "string", "minLength": 1}},
|
| 306 |
+
required=["question"],
|
| 307 |
+
),
|
| 308 |
+
"response_model": "GuruResearchMemo",
|
| 309 |
+
},
|
| 310 |
}
|
| 311 |
|
| 312 |
|
src/TerraFin/agent/tools.py
CHANGED
|
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from collections.abc import Mapping
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
-
from typing import Any, Literal
|
| 6 |
|
| 7 |
from TerraFin.interface.market_insights.payloads import canonical_macro_name, resolve_macro_type
|
| 8 |
from TerraFin.interface.stock.payloads import resolve_ticker_query
|
| 9 |
|
| 10 |
-
from .definitions import TerraFinAgentDefinition
|
| 11 |
from .hosted_runtime import (
|
| 12 |
TerraFinAgentApprovalRequiredError,
|
| 13 |
TerraFinHostedAgentRuntime,
|
|
@@ -16,6 +16,10 @@ from .runtime import TerraFinCapability, TerraFinTaskRecord
|
|
| 16 |
from .tool_contracts import HOSTED_TOOL_CONTRACT_VERSION, get_hosted_tool_contract
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
ToolExecutionMode = Literal["invoke", "task"]
|
| 20 |
|
| 21 |
|
|
@@ -63,9 +67,26 @@ class _ToolErrorDisposition:
|
|
| 63 |
model_hint: str | None = None
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
class TerraFinHostedToolAdapter:
|
| 67 |
def __init__(self, runtime: TerraFinHostedAgentRuntime) -> None:
|
| 68 |
self.runtime = runtime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
def list_tools_for_agent(self, agent_name: str) -> tuple[TerraFinToolDefinition, ...]:
|
| 71 |
definition = self.runtime.get_agent_definition(agent_name)
|
|
@@ -107,6 +128,20 @@ class TerraFinHostedToolAdapter:
|
|
| 107 |
session_id,
|
| 108 |
view_context_id=_optional_string(resolved_arguments.get("viewContextId")),
|
| 109 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
elif tool.execution_mode == "task":
|
| 111 |
task = self.runtime.start_task(session_id, tool.capability_name, **resolved_arguments)
|
| 112 |
payload = {
|
|
@@ -197,8 +232,93 @@ class TerraFinHostedToolAdapter:
|
|
| 197 |
if definition.allow_background_tasks and capability.backgroundable:
|
| 198 |
tools.append(self._build_tool_definition(capability, execution_mode="task"))
|
| 199 |
tools.append(self._build_current_view_context_tool())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return tuple(tools)
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
def _preflight_tool_misuse(
|
| 203 |
self,
|
| 204 |
tool: TerraFinToolDefinition,
|
|
@@ -278,7 +398,42 @@ class TerraFinHostedToolAdapter:
|
|
| 278 |
capability_name="current_view_context",
|
| 279 |
description=(
|
| 280 |
"Read the user's current TerraFin page/view context when the request depends on "
|
| 281 |
-
"what they are looking at right now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
),
|
| 283 |
input_schema=contract["input_schema"],
|
| 284 |
execution_mode="invoke",
|
|
@@ -357,6 +512,39 @@ class TerraFinHostedToolAdapter:
|
|
| 357 |
expose_to_user=True,
|
| 358 |
)
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
name_value = _optional_string(arguments.get("name"))
|
| 361 |
ticker_value = _optional_string(arguments.get("ticker"))
|
| 362 |
requested_value = name_value or ticker_value or ""
|
|
|
|
| 2 |
|
| 3 |
from collections.abc import Mapping
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
+
from typing import TYPE_CHECKING, Any, Literal
|
| 6 |
|
| 7 |
from TerraFin.interface.market_insights.payloads import canonical_macro_name, resolve_macro_type
|
| 8 |
from TerraFin.interface.stock.payloads import resolve_ticker_query
|
| 9 |
|
| 10 |
+
from .definitions import TerraFinAgentDefinition, is_internal_agent_definition
|
| 11 |
from .hosted_runtime import (
|
| 12 |
TerraFinAgentApprovalRequiredError,
|
| 13 |
TerraFinHostedAgentRuntime,
|
|
|
|
| 16 |
from .tool_contracts import HOSTED_TOOL_CONTRACT_VERSION, get_hosted_tool_contract
|
| 17 |
|
| 18 |
|
| 19 |
+
if TYPE_CHECKING:
|
| 20 |
+
from .loop import TerraFinHostedAgentLoop
|
| 21 |
+
|
| 22 |
+
|
| 23 |
ToolExecutionMode = Literal["invoke", "task"]
|
| 24 |
|
| 25 |
|
|
|
|
| 67 |
model_hint: str | None = None
|
| 68 |
|
| 69 |
|
| 70 |
+
_PERSONA_CONSULT_TOOLS = {
|
| 71 |
+
"consult_warren_buffett": "warren-buffett",
|
| 72 |
+
"consult_howard_marks": "howard-marks",
|
| 73 |
+
"consult_stanley_druckenmiller": "stanley-druckenmiller",
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
class TerraFinHostedToolAdapter:
|
| 78 |
def __init__(self, runtime: TerraFinHostedAgentRuntime) -> None:
|
| 79 |
self.runtime = runtime
|
| 80 |
+
# The loop reference is injected after loop construction because the
|
| 81 |
+
# adapter and loop are mutually dependent: the loop owns the adapter;
|
| 82 |
+
# the adapter needs the loop to dispatch `consult_<persona>` tools
|
| 83 |
+
# (persona subagents are full model loops, which the top-level loop
|
| 84 |
+
# runs). Plain session-scoped capability calls don't need the loop —
|
| 85 |
+
# they go through `runtime.invoke`.
|
| 86 |
+
self._loop: "TerraFinHostedAgentLoop" | None = None
|
| 87 |
+
|
| 88 |
+
def attach_loop(self, loop: "TerraFinHostedAgentLoop") -> None:
|
| 89 |
+
self._loop = loop
|
| 90 |
|
| 91 |
def list_tools_for_agent(self, agent_name: str) -> tuple[TerraFinToolDefinition, ...]:
|
| 92 |
definition = self.runtime.get_agent_definition(agent_name)
|
|
|
|
| 128 |
session_id,
|
| 129 |
view_context_id=_optional_string(resolved_arguments.get("viewContextId")),
|
| 130 |
)
|
| 131 |
+
elif tool.name in _PERSONA_CONSULT_TOOLS:
|
| 132 |
+
if self._loop is None:
|
| 133 |
+
raise RuntimeError(
|
| 134 |
+
"Tool adapter has no loop reference; consult_<persona> "
|
| 135 |
+
"tools require `attach_loop(...)` to have been called "
|
| 136 |
+
"after loop construction."
|
| 137 |
+
)
|
| 138 |
+
guru_name = _PERSONA_CONSULT_TOOLS[tool.name]
|
| 139 |
+
question = _optional_string(resolved_arguments.get("question")) or ""
|
| 140 |
+
if not question:
|
| 141 |
+
raise ValueError(
|
| 142 |
+
f"{tool.name} requires a non-empty `question` argument."
|
| 143 |
+
)
|
| 144 |
+
payload = self._loop.consult_guru(session_id, guru_name, question)
|
| 145 |
elif tool.execution_mode == "task":
|
| 146 |
task = self.runtime.start_task(session_id, tool.capability_name, **resolved_arguments)
|
| 147 |
payload = {
|
|
|
|
| 232 |
if definition.allow_background_tasks and capability.backgroundable:
|
| 233 |
tools.append(self._build_tool_definition(capability, execution_mode="task"))
|
| 234 |
tools.append(self._build_current_view_context_tool())
|
| 235 |
+
# Persona-consult tools — only the user-facing orchestrator gets
|
| 236 |
+
# them. Persona subagents themselves (hiddenInternal guru roles)
|
| 237 |
+
# must not recurse through consult_* onto each other, so they are
|
| 238 |
+
# filtered out by `is_internal_agent_definition`.
|
| 239 |
+
if not is_internal_agent_definition(definition):
|
| 240 |
+
tools.append(self._build_consult_warren_buffett_tool())
|
| 241 |
+
tools.append(self._build_consult_howard_marks_tool())
|
| 242 |
+
tools.append(self._build_consult_stanley_druckenmiller_tool())
|
| 243 |
return tuple(tools)
|
| 244 |
|
| 245 |
+
def _build_consult_warren_buffett_tool(self) -> TerraFinToolDefinition:
|
| 246 |
+
contract = get_hosted_tool_contract("consult_warren_buffett")
|
| 247 |
+
return TerraFinToolDefinition(
|
| 248 |
+
name="consult_warren_buffett",
|
| 249 |
+
capability_name="consult_warren_buffett",
|
| 250 |
+
description=(
|
| 251 |
+
"Consult the hidden Warren Buffett subagent for a business-quality / "
|
| 252 |
+
"long-term-ownership lens. Best when the user asks about moats, "
|
| 253 |
+
"competitive advantage, earnings power, capital allocation, intrinsic "
|
| 254 |
+
"value under conservative assumptions, or whether a business is worth "
|
| 255 |
+
"owning for the long run. Pass the specific question as `question`. "
|
| 256 |
+
"Returns a structured memo with stance, confidence (0-100, "
|
| 257 |
+
"self-reported by the persona agent based on evidence quality), "
|
| 258 |
+
"thesis, key_evidence, risks, open_questions, citations. "
|
| 259 |
+
"Call this alongside `consult_howard_marks` or "
|
| 260 |
+
"`consult_stanley_druckenmiller` when the user explicitly asks for "
|
| 261 |
+
"multiple investor perspectives."
|
| 262 |
+
),
|
| 263 |
+
input_schema=contract["input_schema"],
|
| 264 |
+
execution_mode="invoke",
|
| 265 |
+
side_effecting=False,
|
| 266 |
+
metadata={
|
| 267 |
+
"backgroundable": False,
|
| 268 |
+
"capabilityName": "consult_warren_buffett",
|
| 269 |
+
"contractVersion": HOSTED_TOOL_CONTRACT_VERSION,
|
| 270 |
+
"responseModel": contract["response_model"],
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def _build_consult_howard_marks_tool(self) -> TerraFinToolDefinition:
|
| 275 |
+
contract = get_hosted_tool_contract("consult_howard_marks")
|
| 276 |
+
return TerraFinToolDefinition(
|
| 277 |
+
name="consult_howard_marks",
|
| 278 |
+
capability_name="consult_howard_marks",
|
| 279 |
+
description=(
|
| 280 |
+
"Consult the hidden Howard Marks subagent for a cycle / "
|
| 281 |
+
"risk-premium / second-level-thinking lens. Best when the user asks "
|
| 282 |
+
"about downside risk, what's priced in, cycle position, bear cases, "
|
| 283 |
+
"valuation sensitivity, or whether the market is complacent. "
|
| 284 |
+
"Returns a structured memo (same schema as `consult_warren_buffett`). "
|
| 285 |
+
"Complementary to Buffett for quality vs. price tension."
|
| 286 |
+
),
|
| 287 |
+
input_schema=contract["input_schema"],
|
| 288 |
+
execution_mode="invoke",
|
| 289 |
+
side_effecting=False,
|
| 290 |
+
metadata={
|
| 291 |
+
"backgroundable": False,
|
| 292 |
+
"capabilityName": "consult_howard_marks",
|
| 293 |
+
"contractVersion": HOSTED_TOOL_CONTRACT_VERSION,
|
| 294 |
+
"responseModel": contract["response_model"],
|
| 295 |
+
},
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
def _build_consult_stanley_druckenmiller_tool(self) -> TerraFinToolDefinition:
|
| 299 |
+
contract = get_hosted_tool_contract("consult_stanley_druckenmiller")
|
| 300 |
+
return TerraFinToolDefinition(
|
| 301 |
+
name="consult_stanley_druckenmiller",
|
| 302 |
+
capability_name="consult_stanley_druckenmiller",
|
| 303 |
+
description=(
|
| 304 |
+
"Consult the hidden Stanley Druckenmiller subagent for a macro / "
|
| 305 |
+
"liquidity / regime / momentum lens. Best when the user asks about "
|
| 306 |
+
"Fed policy impact, rates higher-for-longer, risk-on/off regime, "
|
| 307 |
+
"liquidity conditions, currency or commodity backdrop, or when an "
|
| 308 |
+
"asset's fate depends more on the macro tape than on its own "
|
| 309 |
+
"fundamentals. Returns a structured memo (same schema)."
|
| 310 |
+
),
|
| 311 |
+
input_schema=contract["input_schema"],
|
| 312 |
+
execution_mode="invoke",
|
| 313 |
+
side_effecting=False,
|
| 314 |
+
metadata={
|
| 315 |
+
"backgroundable": False,
|
| 316 |
+
"capabilityName": "consult_stanley_druckenmiller",
|
| 317 |
+
"contractVersion": HOSTED_TOOL_CONTRACT_VERSION,
|
| 318 |
+
"responseModel": contract["response_model"],
|
| 319 |
+
},
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
def _preflight_tool_misuse(
|
| 323 |
self,
|
| 324 |
tool: TerraFinToolDefinition,
|
|
|
|
| 398 |
capability_name="current_view_context",
|
| 399 |
description=(
|
| 400 |
"Read the user's current TerraFin page/view context when the request depends on "
|
| 401 |
+
"what they are looking at right now (pronouns like 'this', 'their', 'it', or implicit "
|
| 402 |
+
"subjects).\n"
|
| 403 |
+
"If the user is viewing a SEC filing, `selection` will carry "
|
| 404 |
+
"`{ticker, form, accession, primaryDocument, sectionSlug, sectionTitle, "
|
| 405 |
+
"sectionExcerpt, documentUrl, indexUrl}`. Key rules:\n"
|
| 406 |
+
"1. USE `selection.accession` AND `selection.primaryDocument` DIRECTLY (don't "
|
| 407 |
+
"call `sec_filings` first) — the filing the user is viewing is already identified. "
|
| 408 |
+
"But the `selection.sectionSlug` may be stale relative to the filing's current TOC "
|
| 409 |
+
"(the browser cached it earlier). So the correct workflow when user asks about the "
|
| 410 |
+
"in-view filing:\n"
|
| 411 |
+
" a. Call `sec_filing_document(ticker=selection.ticker, accession=selection.accession, "
|
| 412 |
+
"primaryDocument=selection.primaryDocument, form=selection.form)` FIRST to get the "
|
| 413 |
+
"current TOC.\n"
|
| 414 |
+
" b. Pick a `sectionSlug` from that TOC that matches the user's question. For "
|
| 415 |
+
"earnings / revenue / MD&A / financial statements in a 10-K, prefer the LARGEST slug "
|
| 416 |
+
"in Part II — 10-K parsers sometimes leave MD&A and Financial Statements inside an "
|
| 417 |
+
"oversized neighbor slug (e.g. `item-6-reserved` with 200 KB of body).\n"
|
| 418 |
+
" c. Call `sec_filing_section(...)` with the TOC-sourced slug.\n"
|
| 419 |
+
"If `selection.sectionSlug` exists AND the user's question is scoped to that exact "
|
| 420 |
+
"section, you MAY skip step (a) and pass `selection.sectionSlug` directly — but on "
|
| 421 |
+
"a 'section not found' error, always fall back to the TOC workflow above.\n"
|
| 422 |
+
"2. The excerpt is only ~4 KB, usually a small slice of a much larger section "
|
| 423 |
+
"(Item 1 Business in a 10-K is typically 100-200 KB). For any substantive question — "
|
| 424 |
+
"business model, operations, strategy, risk factors, segment descriptions, etc. — "
|
| 425 |
+
"ALWAYS call `sec_filing_section` to get the full body before answering. Writing a "
|
| 426 |
+
"two-sentence summary off the excerpt alone is a UX failure.\n"
|
| 427 |
+
"3. `documentUrl` and `indexUrl` are citation links, NOT places to send the user — "
|
| 428 |
+
"they are ALREADY reading this filing in TerraFin's reader. Do NOT write 'you can "
|
| 429 |
+
"view the filing here' or 'open on EDGAR for details' as a tail to your answer. "
|
| 430 |
+
"Only mention an external link if the user explicitly asks for the source URL.\n"
|
| 431 |
+
"4. Cross-filing pivots: if the user asks about content that lives in a *different* "
|
| 432 |
+
"filing than the one in view (e.g. they're on a 10-Q but ask 'what is their business' — "
|
| 433 |
+
"Item 1 Business is a 10-K section, not a 10-Q section), call `sec_filings(ticker)` "
|
| 434 |
+
"ONCE to get the response, then read `filings_response.latestByForm['10-K']` to get "
|
| 435 |
+
"the target filing's accession + primaryDocument directly. Do NOT scan the "
|
| 436 |
+
"chronological `filings` array — 8-Ks cluster at the top and hide the 10-K/10-Q."
|
| 437 |
),
|
| 438 |
input_schema=contract["input_schema"],
|
| 439 |
execution_mode="invoke",
|
|
|
|
| 512 |
expose_to_user=True,
|
| 513 |
)
|
| 514 |
|
| 515 |
+
# Missing section slug on `sec_filing_section` — the service
|
| 516 |
+
# layer raises LookupError with a full slug list + explicit
|
| 517 |
+
# retry instructions, but without this classifier branch the
|
| 518 |
+
# error bubbles up with no `retryable=True` / `modelHint`
|
| 519 |
+
# signal. The model then paraphrases the error and gives up
|
| 520 |
+
# instead of reissuing the call with a valid slug. Surface it
|
| 521 |
+
# as a retryable tool-input error so the loop retries and the
|
| 522 |
+
# model sees a clear hint. The full slug list from the raised
|
| 523 |
+
# message becomes the `modelHint`.
|
| 524 |
+
if tool.name == "sec_filing_section" and "not found" in lowered and "slug" in lowered:
|
| 525 |
+
requested_slug = _optional_string(arguments.get("sectionSlug"))
|
| 526 |
+
return _ToolErrorDisposition(
|
| 527 |
+
code="sec_filing_section_slug_not_found",
|
| 528 |
+
message=(
|
| 529 |
+
f"The requested section slug "
|
| 530 |
+
f"{repr(requested_slug) if requested_slug else 'provided'} "
|
| 531 |
+
"is not in this filing's TOC. Retry with one of the slugs listed "
|
| 532 |
+
"in the error body."
|
| 533 |
+
),
|
| 534 |
+
retryable=True,
|
| 535 |
+
expose_to_user=False,
|
| 536 |
+
model_hint=(
|
| 537 |
+
"DO NOT tell the user the section doesn't exist. The error body "
|
| 538 |
+
"contains the full TOC slug list with sizes. Pick ONE slug from "
|
| 539 |
+
"that list verbatim and call `sec_filing_section` again — "
|
| 540 |
+
"prefer the largest slug in Part II of a 10-K when the user "
|
| 541 |
+
"asked about earnings / revenue / MD&A / financial statements, "
|
| 542 |
+
"because 10-K parsers sometimes leave those sections buried "
|
| 543 |
+
"inside an oversized neighbor slug. Full error message for "
|
| 544 |
+
"reference:\n" + message
|
| 545 |
+
),
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
name_value = _optional_string(arguments.get("name"))
|
| 549 |
ticker_value = _optional_string(arguments.get("ticker"))
|
| 550 |
requested_value = name_value or ticker_value or ""
|
src/TerraFin/configuration.py
CHANGED
|
@@ -20,6 +20,7 @@ DEFAULT_CACHE_INTERVALS = {
|
|
| 20 |
"yfinance": 43200,
|
| 21 |
"portfolio": 259200,
|
| 22 |
"ticker_info": 43200,
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
DEFAULT_WATCHLIST_DATABASE = "terrafin_status_db"
|
|
|
|
| 20 |
"yfinance": 43200,
|
| 21 |
"portfolio": 259200,
|
| 22 |
"ticker_info": 43200,
|
| 23 |
+
"sec_filings": 2592000,
|
| 24 |
}
|
| 25 |
|
| 26 |
DEFAULT_WATCHLIST_DATABASE = "terrafin_status_db"
|
src/TerraFin/interface/frontend/src/stock/StockPage.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import StockHeader from './components/StockHeader';
|
|
| 12 |
import StockChart from './components/StockChart';
|
| 13 |
import CompanyProfile from './components/CompanyProfile';
|
| 14 |
import EarningsTable from './components/EarningsTable';
|
|
|
|
| 15 |
import { useCompanyInfo, useEarnings } from './useStockData';
|
| 16 |
|
| 17 |
const TICKER_GROUPS = [
|
|
@@ -119,6 +120,14 @@ const StockPage: React.FC = () => {
|
|
| 119 |
setIsReverseDcfOpen(false);
|
| 120 |
}, [ticker]);
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
React.useEffect(() => {
|
| 123 |
const route = window.location.pathname;
|
| 124 |
if (!ticker) {
|
|
@@ -378,6 +387,19 @@ const StockPage: React.FC = () => {
|
|
| 378 |
|
| 379 |
{!isMobile ? dcfResultCard : null}
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
<section style={reverseDcfToggleCardStyle}>
|
| 382 |
<button
|
| 383 |
type="button"
|
|
|
|
| 12 |
import StockChart from './components/StockChart';
|
| 13 |
import CompanyProfile from './components/CompanyProfile';
|
| 14 |
import EarningsTable from './components/EarningsTable';
|
| 15 |
+
import SecFilings from './components/SecFilings';
|
| 16 |
import { useCompanyInfo, useEarnings } from './useStockData';
|
| 17 |
|
| 18 |
const TICKER_GROUPS = [
|
|
|
|
| 120 |
setIsReverseDcfOpen(false);
|
| 121 |
}, [ticker]);
|
| 122 |
|
| 123 |
+
// SEC filings section hides itself for non-US tickers (KOSPI, TSE, etc.)
|
| 124 |
+
// where the ticker has no SEC CIK. Reset on ticker change so switching
|
| 125 |
+
// from 005930.KS to AAPL re-shows the section.
|
| 126 |
+
const [hideSecFilings, setHideSecFilings] = React.useState(false);
|
| 127 |
+
React.useEffect(() => {
|
| 128 |
+
setHideSecFilings(false);
|
| 129 |
+
}, [ticker]);
|
| 130 |
+
|
| 131 |
React.useEffect(() => {
|
| 132 |
const route = window.location.pathname;
|
| 133 |
if (!ticker) {
|
|
|
|
| 387 |
|
| 388 |
{!isMobile ? dcfResultCard : null}
|
| 389 |
|
| 390 |
+
{!hideSecFilings ? (
|
| 391 |
+
<section>
|
| 392 |
+
<InsightCard
|
| 393 |
+
title="SEC Filings"
|
| 394 |
+
subtitle={`10-K / 10-Q filings for ${ticker}. Open a filing to browse section-by-section.`}
|
| 395 |
+
fillContent
|
| 396 |
+
allowOverflow
|
| 397 |
+
>
|
| 398 |
+
<SecFilings ticker={ticker} onUnavailable={() => setHideSecFilings(true)} />
|
| 399 |
+
</InsightCard>
|
| 400 |
+
</section>
|
| 401 |
+
) : null}
|
| 402 |
+
|
| 403 |
<section style={reverseDcfToggleCardStyle}>
|
| 404 |
<button
|
| 405 |
type="button"
|
src/TerraFin/interface/frontend/src/stock/components/FilingMarkdown.test.helper.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Quick sanity harness for parseBlocks without React. Run with:
|
| 2 |
+
// node src/TerraFin/interface/frontend/src/stock/components/FilingMarkdown.test.helper.mjs
|
| 3 |
+
// Mirrors the block-parsing logic of FilingMarkdown.tsx.
|
| 4 |
+
|
| 5 |
+
const HEADING_RE = /^(#{2,4})\s+(.*?)\s*$/;
|
| 6 |
+
const TABLE_SEPARATOR_RE = /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
| 7 |
+
const IMAGE_RE = /^!\[([^\]]*)\]\(([^)]+)\)$/;
|
| 8 |
+
|
| 9 |
+
function splitTableCells(line) {
|
| 10 |
+
const parts = line.split('|').map((s) => s.trim());
|
| 11 |
+
if (parts.length > 0 && parts[0] === '') parts.shift();
|
| 12 |
+
if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
|
| 13 |
+
return parts;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function parseBlocks(md) {
|
| 17 |
+
const lines = md.split('\n');
|
| 18 |
+
const blocks = [];
|
| 19 |
+
let i = 0;
|
| 20 |
+
while (i < lines.length) {
|
| 21 |
+
const line = lines[i];
|
| 22 |
+
if (!line.trim()) { i++; continue; }
|
| 23 |
+
const h = line.match(HEADING_RE);
|
| 24 |
+
if (h) { blocks.push({ kind: 'heading', level: h[1].length, text: h[2] }); i++; continue; }
|
| 25 |
+
const img = line.match(IMAGE_RE);
|
| 26 |
+
if (img) { blocks.push({ kind: 'image', alt: img[1], src: img[2] }); i++; continue; }
|
| 27 |
+
if (line.startsWith('> ')) {
|
| 28 |
+
const quote = [];
|
| 29 |
+
while (i < lines.length && lines[i].startsWith('> ')) { quote.push(lines[i].slice(2)); i++; }
|
| 30 |
+
blocks.push({ kind: 'blockquote', text: quote.join('\n') }); continue;
|
| 31 |
+
}
|
| 32 |
+
if (line.includes('|') && i + 1 < lines.length && TABLE_SEPARATOR_RE.test(lines[i + 1])) {
|
| 33 |
+
const header = splitTableCells(line);
|
| 34 |
+
i += 2;
|
| 35 |
+
const rows = [];
|
| 36 |
+
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
| 37 |
+
rows.push(splitTableCells(lines[i])); i++;
|
| 38 |
+
}
|
| 39 |
+
blocks.push({ kind: 'table', header, rows }); continue;
|
| 40 |
+
}
|
| 41 |
+
const paragraph = [line]; i++;
|
| 42 |
+
while (
|
| 43 |
+
i < lines.length && lines[i].trim()
|
| 44 |
+
&& !lines[i].match(HEADING_RE)
|
| 45 |
+
&& !lines[i].startsWith('> ')
|
| 46 |
+
&& !lines[i].match(IMAGE_RE)
|
| 47 |
+
&& !(lines[i].includes('|') && i + 1 < lines.length && TABLE_SEPARATOR_RE.test(lines[i + 1]))
|
| 48 |
+
) { paragraph.push(lines[i]); i++; }
|
| 49 |
+
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') });
|
| 50 |
+
}
|
| 51 |
+
return blocks;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function assertEq(actual, expected, msg) {
|
| 55 |
+
const a = JSON.stringify(actual);
|
| 56 |
+
const e = JSON.stringify(expected);
|
| 57 |
+
if (a !== e) { console.error(`FAIL: ${msg}\n expected: ${e}\n actual: ${a}`); process.exit(1); }
|
| 58 |
+
console.log(`ok: ${msg}`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// --- Real edge cases flagged by the devil's advocate ---
|
| 62 |
+
|
| 63 |
+
// 1. Blockquote table-error fallback (from parser.py:122).
|
| 64 |
+
const withFallback = `## PART I
|
| 65 |
+
|
| 66 |
+
### Item 1
|
| 67 |
+
|
| 68 |
+
Intro text.
|
| 69 |
+
|
| 70 |
+
> [Table parse error: ragged rows; raw markdown follows]
|
| 71 |
+
> | Col A | Col B |
|
| 72 |
+
> | 1 | 2 |
|
| 73 |
+
|
| 74 |
+
After the table.`;
|
| 75 |
+
const blocks1 = parseBlocks(withFallback);
|
| 76 |
+
assertEq(
|
| 77 |
+
blocks1.map((b) => b.kind),
|
| 78 |
+
['heading', 'heading', 'paragraph', 'blockquote', 'paragraph'],
|
| 79 |
+
'blockquote fallback recognized, paragraphs around it intact',
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
// 2. nan cells in a table (from astype(str) on NaN).
|
| 83 |
+
const withNan = `| Metric | Q1 | Q2 |
|
| 84 |
+
| --- | --- | --- |
|
| 85 |
+
| Revenue | 100 | 120 |
|
| 86 |
+
| Other | nan | nan |`;
|
| 87 |
+
const blocks2 = parseBlocks(withNan);
|
| 88 |
+
assertEq(blocks2.length, 1, 'nan-bearing table is still one table block');
|
| 89 |
+
assertEq(blocks2[0].kind, 'table', 'nan-bearing block kind is table');
|
| 90 |
+
assertEq(blocks2[0].rows[1], ['Other', 'nan', 'nan'], 'nan survives the parse (cleaned at render)');
|
| 91 |
+
|
| 92 |
+
// 3. Inline-image placeholder (from our data-URI replacement).
|
| 93 |
+
const withPlaceholder = ``;
|
| 94 |
+
const blocks3 = parseBlocks(withPlaceholder);
|
| 95 |
+
assertEq(blocks3.length, 1, 'inline-image is one block');
|
| 96 |
+
assertEq(blocks3[0], { kind: 'image', alt: 'Company logo', src: '<inline-image:image/png>' }, 'placeholder src preserved');
|
| 97 |
+
|
| 98 |
+
// 4. Paragraph with mid-text dollar signs and percent (won't be mistaken for a table).
|
| 99 |
+
const withDollars = `Revenue grew 12.3% to $48.5B, driven by services.`;
|
| 100 |
+
const blocks4 = parseBlocks(withDollars);
|
| 101 |
+
assertEq(blocks4.map((b) => b.kind), ['paragraph'], 'currency/percent paragraph stays paragraph');
|
| 102 |
+
|
| 103 |
+
// 5. Line with pipes but no separator below — NOT a table.
|
| 104 |
+
const withPipesNoSep = `Contact us at sales | support | marketing for details.
|
| 105 |
+
|
| 106 |
+
More text.`;
|
| 107 |
+
const blocks5 = parseBlocks(withPipesNoSep);
|
| 108 |
+
assertEq(blocks5.map((b) => b.kind), ['paragraph', 'paragraph'], 'pipes without separator is paragraph, not table');
|
| 109 |
+
|
| 110 |
+
// 6. Heading levels.
|
| 111 |
+
const headings = `## Two\n### Three\n#### Four`;
|
| 112 |
+
const blocks6 = parseBlocks(headings);
|
| 113 |
+
assertEq(blocks6.map((b) => b.level), [2, 3, 4], 'h2/h3/h4 levels preserved');
|
| 114 |
+
|
| 115 |
+
console.log('\nAll FilingMarkdown edge-case checks passed.');
|
src/TerraFin/interface/frontend/src/stock/components/FilingMarkdown.tsx
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
// Minimal markdown renderer tailored to TerraFin's `parse_sec_filing` output.
|
| 4 |
+
// Handles: ##/### headings, blank-line-separated paragraphs, GFM pipe tables,
|
| 5 |
+
// blockquote table-error fallbacks (`> [Table parse error: ...]\nRAW_MD`), and
|
| 6 |
+
// optional `` images. Anything else falls through as a plain line.
|
| 7 |
+
//
|
| 8 |
+
// Deliberately NOT a general markdown renderer — adding that would mean
|
| 9 |
+
// pulling in react-markdown + remark-gfm (~60KB gz) for output we fully
|
| 10 |
+
// control the producer of.
|
| 11 |
+
|
| 12 |
+
interface Props {
|
| 13 |
+
markdown: string;
|
| 14 |
+
lineIndexStart?: number; // so headings can carry stable ids for scroll-target
|
| 15 |
+
lineIndexEnd?: number;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const H2_STYLE: React.CSSProperties = { fontSize: 16, fontWeight: 800, color: '#0f172a', margin: '18px 0 8px', lineHeight: 1.3 };
|
| 19 |
+
const H3_STYLE: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: '#1e293b', margin: '14px 0 6px', lineHeight: 1.35 };
|
| 20 |
+
const H4_STYLE: React.CSSProperties = { fontSize: 13, fontWeight: 700, color: '#334155', margin: '12px 0 4px' };
|
| 21 |
+
const P_STYLE: React.CSSProperties = { fontSize: 13, color: '#334155', lineHeight: 1.6, margin: '0 0 8px' };
|
| 22 |
+
const BLOCKQUOTE_STYLE: React.CSSProperties = {
|
| 23 |
+
borderLeft: '3px solid #fb923c',
|
| 24 |
+
background: '#fff7ed',
|
| 25 |
+
color: '#9a3412',
|
| 26 |
+
padding: '8px 12px',
|
| 27 |
+
fontSize: 12,
|
| 28 |
+
margin: '8px 0',
|
| 29 |
+
borderRadius: 4,
|
| 30 |
+
};
|
| 31 |
+
const TABLE_STYLE: React.CSSProperties = { borderCollapse: 'collapse', fontSize: 12, margin: '8px 0', width: '100%' };
|
| 32 |
+
const TH_STYLE: React.CSSProperties = { border: '1px solid #e2e8f0', background: '#f1f5f9', padding: '6px 8px', textAlign: 'left', fontWeight: 700 };
|
| 33 |
+
const TD_STYLE: React.CSSProperties = { border: '1px solid #e2e8f0', padding: '6px 8px', verticalAlign: 'top' };
|
| 34 |
+
const IMG_STYLE: React.CSSProperties = { maxWidth: '100%', border: '1px solid #e2e8f0', borderRadius: 4, margin: '8px 0' };
|
| 35 |
+
const PLACEHOLDER_STYLE: React.CSSProperties = { ...P_STYLE, color: '#94a3b8', fontStyle: 'italic' };
|
| 36 |
+
|
| 37 |
+
const HEADING_RE = /^(#{2,4})\s+(.*?)\s*$/;
|
| 38 |
+
const TABLE_SEPARATOR_RE = /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
|
| 39 |
+
const IMAGE_RE = /^!\[([^\]]*)\]\(([^)]+)\)$/;
|
| 40 |
+
|
| 41 |
+
function splitTableCells(line: string): string[] {
|
| 42 |
+
// Simple pipe-split. sec_parser table cells occasionally contain escaped pipes
|
| 43 |
+
// or currency strings; we accept the naive split and clean leading/trailing
|
| 44 |
+
// empty cells that come from wrapper pipes like "| a | b |".
|
| 45 |
+
const parts = line.split('|').map((s) => s.trim());
|
| 46 |
+
if (parts.length > 0 && parts[0] === '') parts.shift();
|
| 47 |
+
if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
|
| 48 |
+
return parts;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function cleanCell(text: string): string {
|
| 52 |
+
// `_modify_to_valid_md_table` uses `astype(str)` which emits literal "nan"
|
| 53 |
+
// for missing values. Scrub these to empty so tables don't look broken.
|
| 54 |
+
if (text === 'nan' || text === 'NaN' || text === 'None') return '';
|
| 55 |
+
return text;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
interface Block {
|
| 59 |
+
kind: 'heading' | 'paragraph' | 'table' | 'blockquote' | 'image' | 'empty';
|
| 60 |
+
payload: unknown;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function parseBlocks(md: string): Block[] {
|
| 64 |
+
const lines = md.split('\n');
|
| 65 |
+
const blocks: Block[] = [];
|
| 66 |
+
let i = 0;
|
| 67 |
+
|
| 68 |
+
while (i < lines.length) {
|
| 69 |
+
const line = lines[i];
|
| 70 |
+
|
| 71 |
+
if (!line.trim()) {
|
| 72 |
+
i++;
|
| 73 |
+
continue;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Heading
|
| 77 |
+
const headingMatch = line.match(HEADING_RE);
|
| 78 |
+
if (headingMatch) {
|
| 79 |
+
blocks.push({ kind: 'heading', payload: { level: headingMatch[1].length, text: headingMatch[2] } });
|
| 80 |
+
i++;
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Standalone image
|
| 85 |
+
const imageMatch = line.match(IMAGE_RE);
|
| 86 |
+
if (imageMatch) {
|
| 87 |
+
blocks.push({ kind: 'image', payload: { alt: imageMatch[1], src: imageMatch[2] } });
|
| 88 |
+
i++;
|
| 89 |
+
continue;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Blockquote run (contiguous `> ` lines)
|
| 93 |
+
if (line.startsWith('> ')) {
|
| 94 |
+
const quote: string[] = [];
|
| 95 |
+
while (i < lines.length && lines[i].startsWith('> ')) {
|
| 96 |
+
quote.push(lines[i].slice(2));
|
| 97 |
+
i++;
|
| 98 |
+
}
|
| 99 |
+
blocks.push({ kind: 'blockquote', payload: quote.join('\n') });
|
| 100 |
+
continue;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Possible pipe table: current line has `|` AND next line is a separator.
|
| 104 |
+
if (line.includes('|') && i + 1 < lines.length && TABLE_SEPARATOR_RE.test(lines[i + 1])) {
|
| 105 |
+
const header = splitTableCells(line);
|
| 106 |
+
i += 2; // skip header + separator
|
| 107 |
+
const rows: string[][] = [];
|
| 108 |
+
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
| 109 |
+
rows.push(splitTableCells(lines[i]));
|
| 110 |
+
i++;
|
| 111 |
+
}
|
| 112 |
+
blocks.push({ kind: 'table', payload: { header, rows } });
|
| 113 |
+
continue;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Paragraph run (contiguous non-blank, non-special lines).
|
| 117 |
+
const paragraph: string[] = [line];
|
| 118 |
+
i++;
|
| 119 |
+
while (
|
| 120 |
+
i < lines.length
|
| 121 |
+
&& lines[i].trim()
|
| 122 |
+
&& !lines[i].match(HEADING_RE)
|
| 123 |
+
&& !lines[i].startsWith('> ')
|
| 124 |
+
&& !lines[i].match(IMAGE_RE)
|
| 125 |
+
&& !(
|
| 126 |
+
lines[i].includes('|')
|
| 127 |
+
&& i + 1 < lines.length
|
| 128 |
+
&& TABLE_SEPARATOR_RE.test(lines[i + 1])
|
| 129 |
+
)
|
| 130 |
+
) {
|
| 131 |
+
paragraph.push(lines[i]);
|
| 132 |
+
i++;
|
| 133 |
+
}
|
| 134 |
+
blocks.push({ kind: 'paragraph', payload: paragraph.join(' ') });
|
| 135 |
+
}
|
| 136 |
+
return blocks;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const FilingMarkdown: React.FC<Props> = ({ markdown }) => {
|
| 140 |
+
const blocks = React.useMemo(() => parseBlocks(markdown), [markdown]);
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<div>
|
| 144 |
+
{blocks.map((block, idx) => {
|
| 145 |
+
switch (block.kind) {
|
| 146 |
+
case 'heading': {
|
| 147 |
+
const { level, text } = block.payload as { level: number; text: string };
|
| 148 |
+
const style = level === 2 ? H2_STYLE : level === 3 ? H3_STYLE : H4_STYLE;
|
| 149 |
+
if (level === 2) return <h2 key={idx} style={style}>{text}</h2>;
|
| 150 |
+
if (level === 3) return <h3 key={idx} style={style}>{text}</h3>;
|
| 151 |
+
return <h4 key={idx} style={style}>{text}</h4>;
|
| 152 |
+
}
|
| 153 |
+
case 'paragraph': {
|
| 154 |
+
return <p key={idx} style={P_STYLE}>{block.payload as string}</p>;
|
| 155 |
+
}
|
| 156 |
+
case 'blockquote': {
|
| 157 |
+
return <blockquote key={idx} style={BLOCKQUOTE_STYLE}>{block.payload as string}</blockquote>;
|
| 158 |
+
}
|
| 159 |
+
case 'table': {
|
| 160 |
+
const { header, rows } = block.payload as { header: string[]; rows: string[][] };
|
| 161 |
+
return (
|
| 162 |
+
<div key={idx} style={{ overflowX: 'auto' }}>
|
| 163 |
+
<table style={TABLE_STYLE}>
|
| 164 |
+
<thead>
|
| 165 |
+
<tr>{header.map((h, hi) => (<th key={hi} style={TH_STYLE}>{cleanCell(h)}</th>))}</tr>
|
| 166 |
+
</thead>
|
| 167 |
+
<tbody>
|
| 168 |
+
{rows.map((row, ri) => (
|
| 169 |
+
<tr key={ri}>
|
| 170 |
+
{row.map((cell, ci) => (<td key={ci} style={TD_STYLE}>{cleanCell(cell)}</td>))}
|
| 171 |
+
</tr>
|
| 172 |
+
))}
|
| 173 |
+
</tbody>
|
| 174 |
+
</table>
|
| 175 |
+
</div>
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
case 'image': {
|
| 179 |
+
const { alt, src } = block.payload as { alt: string; src: string };
|
| 180 |
+
// Our parser replaces data URIs with `<inline-image:...>` placeholders
|
| 181 |
+
// that aren't fetchable. Render them as a visible stub instead of a
|
| 182 |
+
// broken <img>.
|
| 183 |
+
if (src.startsWith('<inline-image')) {
|
| 184 |
+
return <div key={idx} style={PLACEHOLDER_STYLE}>[inline image: {alt || 'no alt text'}]</div>;
|
| 185 |
+
}
|
| 186 |
+
return <img key={idx} src={src} alt={alt} style={IMG_STYLE} />;
|
| 187 |
+
}
|
| 188 |
+
default:
|
| 189 |
+
return null;
|
| 190 |
+
}
|
| 191 |
+
})}
|
| 192 |
+
</div>
|
| 193 |
+
);
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
export default FilingMarkdown;
|
src/TerraFin/interface/frontend/src/stock/components/SecFilings.tsx
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { publishAgentViewContext, clearAgentViewContextSource } from '../../agent/viewContext';
|
| 3 |
+
import type { FilingRow, TocEntry } from '../useStockData';
|
| 4 |
+
import { useFilingDocument, useFilings } from '../useStockData';
|
| 5 |
+
import FilingMarkdown from './FilingMarkdown';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
ticker: string;
|
| 9 |
+
// Fired when the ticker has no SEC registration (CIK lookup 404s). The parent
|
| 10 |
+
// uses this to hide the whole card — non-US tickers like KOSPI / TSE / HKEX
|
| 11 |
+
// issuers don't file with SEC, and an empty card is just noise.
|
| 12 |
+
onUnavailable?: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Maximum chars of section body we publish to the agent side-panel.
|
| 16 |
+
// Balances "agent can answer questions about this section" against context
|
| 17 |
+
// size. Larger sections get truncated with an explicit marker.
|
| 18 |
+
const AGENT_EXCERPT_CHAR_LIMIT = 4000;
|
| 19 |
+
|
| 20 |
+
const PART_HEADING_RE = /^PART\s/i;
|
| 21 |
+
|
| 22 |
+
interface FilingGroup {
|
| 23 |
+
part: TocEntry | null; // null only for headings that precede any Part (rare)
|
| 24 |
+
items: TocEntry[];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Render every Item the TOC returns — user↔agent parity trumps UI
|
| 28 |
+
// tidiness. Empty-placeholder Items (e.g. the literal "Item 6.
|
| 29 |
+
// Reserved" left in 10-Ks after the SEC removed it) render with a
|
| 30 |
+
// 0-char body; they're unobtrusive when collapsed and stopping the
|
| 31 |
+
// agent from citing a section the user can't see is worse than a
|
| 32 |
+
// small visual noise bump.
|
| 33 |
+
function groupTocByPart(toc: TocEntry[]): FilingGroup[] {
|
| 34 |
+
const groups: FilingGroup[] = [];
|
| 35 |
+
let current: FilingGroup = { part: null, items: [] };
|
| 36 |
+
for (const entry of toc) {
|
| 37 |
+
if (PART_HEADING_RE.test(entry.text)) {
|
| 38 |
+
if (current.part || current.items.length > 0) groups.push(current);
|
| 39 |
+
current = { part: entry, items: [] };
|
| 40 |
+
} else {
|
| 41 |
+
current.items.push(entry);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
if (current.part || current.items.length > 0) groups.push(current);
|
| 45 |
+
return groups;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function groupKey(group: FilingGroup): string {
|
| 49 |
+
return group.part?.slug ?? '_orphan_';
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function sliceSection(markdown: string, entry: TocEntry, next: TocEntry | undefined): string {
|
| 53 |
+
const lines = markdown.split('\n');
|
| 54 |
+
const endLine = next ? next.lineIndex : lines.length;
|
| 55 |
+
// Skip the heading line itself so the body doesn't repeat it.
|
| 56 |
+
return lines.slice(entry.lineIndex + 1, endLine).join('\n').trim();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function excerptForAgent(body: string): string {
|
| 60 |
+
if (body.length <= AGENT_EXCERPT_CHAR_LIMIT) return body;
|
| 61 |
+
return body.slice(0, AGENT_EXCERPT_CHAR_LIMIT) + '\n\n…[excerpt truncated]';
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const SecFilings: React.FC<Props> = ({ ticker, onUnavailable }) => {
|
| 65 |
+
const { data: filingsList, loading: listLoading, error: listError } = useFilings(ticker);
|
| 66 |
+
|
| 67 |
+
// Signal the parent once the ticker is confirmed not to be an SEC filer.
|
| 68 |
+
React.useEffect(() => {
|
| 69 |
+
if (listError === '404') onUnavailable?.();
|
| 70 |
+
}, [listError, onUnavailable]);
|
| 71 |
+
const [selectedForm, setSelectedForm] = React.useState<string | null>(null);
|
| 72 |
+
|
| 73 |
+
// Auto-pick the best form when the list first arrives: prefer 10-K, then 10-Q,
|
| 74 |
+
// then whatever comes first.
|
| 75 |
+
React.useEffect(() => {
|
| 76 |
+
if (!filingsList || filingsList.forms.length === 0) {
|
| 77 |
+
setSelectedForm(null);
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
const preferred = ['10-K', '10-Q'].find((f) => filingsList.forms.includes(f));
|
| 81 |
+
setSelectedForm(preferred || filingsList.forms[0]);
|
| 82 |
+
}, [filingsList]);
|
| 83 |
+
|
| 84 |
+
const filteredFilings = React.useMemo<FilingRow[]>(() => {
|
| 85 |
+
if (!filingsList || !selectedForm) return [];
|
| 86 |
+
return filingsList.filings.filter((f) => f.form === selectedForm);
|
| 87 |
+
}, [filingsList, selectedForm]);
|
| 88 |
+
|
| 89 |
+
const [selectedAccession, setSelectedAccession] = React.useState<string | null>(null);
|
| 90 |
+
React.useEffect(() => {
|
| 91 |
+
// On form change, collapse the reader until the user picks a filing.
|
| 92 |
+
setSelectedAccession(null);
|
| 93 |
+
}, [selectedForm, ticker]);
|
| 94 |
+
|
| 95 |
+
const selectedFiling = React.useMemo<FilingRow | null>(
|
| 96 |
+
() => filteredFilings.find((f) => f.accession === selectedAccession) ?? null,
|
| 97 |
+
[filteredFilings, selectedAccession],
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
const { data: filingDoc, loading: docLoading, error: docError } = useFilingDocument(
|
| 101 |
+
ticker,
|
| 102 |
+
selectedFiling?.accession ?? null,
|
| 103 |
+
selectedFiling?.primaryDocument ?? null,
|
| 104 |
+
selectedFiling?.form ?? '10-Q',
|
| 105 |
+
Boolean(selectedFiling),
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
// Two-level navigation: outer Parts (Part I / Part II / ...), each holding
|
| 109 |
+
// its Items. Parts are the structural halves of a 10-K / 10-Q; Items are
|
| 110 |
+
// the actual content sections.
|
| 111 |
+
const groups = React.useMemo(
|
| 112 |
+
() => (filingDoc ? groupTocByPart(filingDoc.toc) : []),
|
| 113 |
+
[filingDoc],
|
| 114 |
+
);
|
| 115 |
+
|
| 116 |
+
// Default-open state: all Parts expanded so users can scan, plus the first
|
| 117 |
+
// Item of the first Part so the reader isn't empty on open.
|
| 118 |
+
const [openParts, setOpenParts] = React.useState<Set<string>>(new Set());
|
| 119 |
+
const [openItems, setOpenItems] = React.useState<Set<string>>(new Set());
|
| 120 |
+
React.useEffect(() => {
|
| 121 |
+
if (groups.length === 0) {
|
| 122 |
+
setOpenParts(new Set());
|
| 123 |
+
setOpenItems(new Set());
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
setOpenParts(new Set(groups.map(groupKey)));
|
| 127 |
+
const first = groups[0].items[0];
|
| 128 |
+
setOpenItems(first ? new Set([first.slug]) : new Set());
|
| 129 |
+
}, [groups]);
|
| 130 |
+
|
| 131 |
+
const togglePart = (key: string) => {
|
| 132 |
+
setOpenParts((prev) => {
|
| 133 |
+
const next = new Set(prev);
|
| 134 |
+
if (next.has(key)) next.delete(key);
|
| 135 |
+
else next.add(key);
|
| 136 |
+
return next;
|
| 137 |
+
});
|
| 138 |
+
};
|
| 139 |
+
const toggleItem = (slug: string) => {
|
| 140 |
+
setOpenItems((prev) => {
|
| 141 |
+
const next = new Set(prev);
|
| 142 |
+
if (next.has(slug)) next.delete(slug);
|
| 143 |
+
else next.add(slug);
|
| 144 |
+
return next;
|
| 145 |
+
});
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// Publish agent view context: the currently-focused Item (Parts have no
|
| 149 |
+
// body themselves, so we track Item focus only). Picks the first open Item
|
| 150 |
+
// in document order — keeps the agent's context small even with multiple
|
| 151 |
+
// expanded.
|
| 152 |
+
const visibleItems = React.useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
| 153 |
+
const focusedSlug = openItems.size > 0
|
| 154 |
+
? visibleItems.find((e) => openItems.has(e.slug))?.slug
|
| 155 |
+
: undefined;
|
| 156 |
+
|
| 157 |
+
React.useEffect(() => {
|
| 158 |
+
if (!selectedFiling || !filingDoc || !focusedSlug) {
|
| 159 |
+
return;
|
| 160 |
+
}
|
| 161 |
+
const entry = visibleItems.find((e) => e.slug === focusedSlug);
|
| 162 |
+
if (!entry) return;
|
| 163 |
+
// Use the next *raw* TOC entry (not the next visible one) as the slice
|
| 164 |
+
// upper bound so we stop at the exact next heading in the markdown.
|
| 165 |
+
const rawIdx = filingDoc.toc.indexOf(entry);
|
| 166 |
+
const nextEntry = filingDoc.toc[rawIdx + 1];
|
| 167 |
+
const body = sliceSection(filingDoc.markdown, entry, nextEntry);
|
| 168 |
+
|
| 169 |
+
void publishAgentViewContext({
|
| 170 |
+
source: 'sec-filings',
|
| 171 |
+
scope: 'panel',
|
| 172 |
+
route: window.location.pathname,
|
| 173 |
+
pageType: 'stock',
|
| 174 |
+
title: `${ticker} ${selectedFiling.form} — ${entry.text}`,
|
| 175 |
+
summary: `Viewing ${selectedFiling.form} (filed ${selectedFiling.filingDate}), section "${entry.text}".`,
|
| 176 |
+
selection: {
|
| 177 |
+
ticker,
|
| 178 |
+
// Matches the `form` parameter name on sec_filing_section so the agent
|
| 179 |
+
// can plug this straight back in without a rename.
|
| 180 |
+
form: selectedFiling.form,
|
| 181 |
+
accession: selectedFiling.accession,
|
| 182 |
+
// Required to call sec_filing_section for a fuller body when the
|
| 183 |
+
// 4 KB excerpt below isn't enough (Item 1. Business in a large 10-K
|
| 184 |
+
// can be 30-50 KB).
|
| 185 |
+
primaryDocument: selectedFiling.primaryDocument,
|
| 186 |
+
filingDate: selectedFiling.filingDate,
|
| 187 |
+
reportDate: selectedFiling.reportDate,
|
| 188 |
+
sectionSlug: entry.slug,
|
| 189 |
+
sectionTitle: entry.text,
|
| 190 |
+
sectionExcerpt: excerptForAgent(body),
|
| 191 |
+
documentUrl: filingDoc.documentUrl,
|
| 192 |
+
indexUrl: filingDoc.indexUrl,
|
| 193 |
+
},
|
| 194 |
+
entities: [
|
| 195 |
+
{ kind: 'ticker', id: ticker, label: ticker },
|
| 196 |
+
{ kind: 'sec-filing', id: selectedFiling.accession, label: `${selectedFiling.form} ${selectedFiling.filingDate}` },
|
| 197 |
+
],
|
| 198 |
+
metadata: { source: 'sec-filings' },
|
| 199 |
+
});
|
| 200 |
+
return () => {
|
| 201 |
+
void clearAgentViewContextSource('sec-filings');
|
| 202 |
+
};
|
| 203 |
+
}, [filingDoc, focusedSlug, selectedFiling, ticker, visibleItems]);
|
| 204 |
+
|
| 205 |
+
return (
|
| 206 |
+
<div style={rootStyle}>
|
| 207 |
+
{listError === '404' ? (
|
| 208 |
+
// Parent will unmount us via onUnavailable. Render nothing in the
|
| 209 |
+
// meantime to avoid an error flash.
|
| 210 |
+
null
|
| 211 |
+
) : listLoading && !filingsList ? (
|
| 212 |
+
<div style={dimStyle}>Loading filings…</div>
|
| 213 |
+
) : listError ? (
|
| 214 |
+
<div style={errorStyle}>Could not load filings: {listError}</div>
|
| 215 |
+
) : !filingsList || filingsList.filings.length === 0 ? (
|
| 216 |
+
<div style={dimStyle}>No SEC filings found for {ticker}.</div>
|
| 217 |
+
) : (
|
| 218 |
+
<>
|
| 219 |
+
<div style={formRowStyle}>
|
| 220 |
+
<label htmlFor="filings-form" style={formLabelStyle}>Form</label>
|
| 221 |
+
<select
|
| 222 |
+
id="filings-form"
|
| 223 |
+
value={selectedForm ?? ''}
|
| 224 |
+
onChange={(e) => setSelectedForm(e.target.value || null)}
|
| 225 |
+
style={selectStyle}
|
| 226 |
+
>
|
| 227 |
+
{filingsList.forms.map((f) => (<option key={f} value={f}>{f}</option>))}
|
| 228 |
+
</select>
|
| 229 |
+
<span style={dimStyle}>{filteredFilings.length} filing{filteredFilings.length === 1 ? '' : 's'}</span>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div style={filingListStyle}>
|
| 233 |
+
{filteredFilings.map((row) => {
|
| 234 |
+
const isSelected = row.accession === selectedAccession;
|
| 235 |
+
return (
|
| 236 |
+
<div
|
| 237 |
+
key={row.accession}
|
| 238 |
+
style={filingRowStyle(isSelected)}
|
| 239 |
+
onClick={() => setSelectedAccession(isSelected ? null : row.accession)}
|
| 240 |
+
role="button"
|
| 241 |
+
tabIndex={0}
|
| 242 |
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setSelectedAccession(isSelected ? null : row.accession); }}
|
| 243 |
+
>
|
| 244 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 245 |
+
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a' }}>{row.form}</div>
|
| 246 |
+
<div style={{ fontSize: 12, color: '#64748b' }}>
|
| 247 |
+
Filed {row.filingDate}{row.reportDate ? ` · Period ${row.reportDate}` : ''}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
<a
|
| 251 |
+
href={row.documentUrl}
|
| 252 |
+
target="_blank"
|
| 253 |
+
rel="noopener noreferrer"
|
| 254 |
+
onClick={(e) => e.stopPropagation()}
|
| 255 |
+
style={edgarLinkStyle}
|
| 256 |
+
>View on EDGAR ↗</a>
|
| 257 |
+
</div>
|
| 258 |
+
);
|
| 259 |
+
})}
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
{selectedFiling ? (
|
| 263 |
+
<div style={readerWrapStyle}>
|
| 264 |
+
<div style={readerHeaderStyle}>
|
| 265 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 266 |
+
<div style={{ fontSize: 14, fontWeight: 800, color: '#0f172a' }}>
|
| 267 |
+
{selectedFiling.form} · Filed {selectedFiling.filingDate}
|
| 268 |
+
</div>
|
| 269 |
+
{selectedFiling.primaryDocDescription ? (
|
| 270 |
+
<div style={{ fontSize: 12, color: '#64748b' }}>{selectedFiling.primaryDocDescription}</div>
|
| 271 |
+
) : null}
|
| 272 |
+
</div>
|
| 273 |
+
{filingDoc ? (
|
| 274 |
+
<a href={filingDoc.documentUrl} target="_blank" rel="noopener noreferrer" style={edgarPillStyle}>
|
| 275 |
+
View source on EDGAR ↗
|
| 276 |
+
</a>
|
| 277 |
+
) : null}
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
{docLoading ? (
|
| 281 |
+
<div style={dimStyle}>Loading filing…</div>
|
| 282 |
+
) : docError ? (
|
| 283 |
+
docError === '422' ? (
|
| 284 |
+
<div style={unsupportedStyle}>
|
| 285 |
+
<div style={{ fontSize: 13, color: '#1e293b', marginBottom: 6 }}>
|
| 286 |
+
In-app reader doesn’t support <strong>{selectedFiling.form}</strong> filings yet.
|
| 287 |
+
</div>
|
| 288 |
+
<a href={selectedFiling.documentUrl} target="_blank" rel="noopener noreferrer" style={edgarPillStyle}>
|
| 289 |
+
Open on EDGAR ↗
|
| 290 |
+
</a>
|
| 291 |
+
</div>
|
| 292 |
+
) : (
|
| 293 |
+
<div style={errorStyle}>Could not load filing: {docError}</div>
|
| 294 |
+
)
|
| 295 |
+
) : filingDoc ? (
|
| 296 |
+
groups.length === 0 ? (
|
| 297 |
+
<div style={dimStyle}>Filing has no sections to navigate. Open on EDGAR for the raw document.</div>
|
| 298 |
+
) : (
|
| 299 |
+
<div style={accordionStyle}>
|
| 300 |
+
{groups.map((group) => {
|
| 301 |
+
const key = groupKey(group);
|
| 302 |
+
const partOpen = openParts.has(key);
|
| 303 |
+
const totalChars = group.items.reduce((acc, it) => acc + it.charCount, 0);
|
| 304 |
+
return (
|
| 305 |
+
<div key={key} style={partCardStyle}>
|
| 306 |
+
<button
|
| 307 |
+
type="button"
|
| 308 |
+
onClick={() => togglePart(key)}
|
| 309 |
+
aria-expanded={partOpen}
|
| 310 |
+
style={partHeaderStyle(partOpen)}
|
| 311 |
+
>
|
| 312 |
+
<span style={headerLeftStyle(10)}>
|
| 313 |
+
<span style={chevronStyle(partOpen)}>▸</span>
|
| 314 |
+
<span style={headerTitleStyle(14, 800, '#0f172a')}>
|
| 315 |
+
{group.part?.text ?? 'Sections'}
|
| 316 |
+
</span>
|
| 317 |
+
</span>
|
| 318 |
+
<span style={headerBadgeStyle}>
|
| 319 |
+
{group.items.length} item{group.items.length === 1 ? '' : 's'} · {totalChars.toLocaleString()} chars
|
| 320 |
+
</span>
|
| 321 |
+
</button>
|
| 322 |
+
{partOpen ? (
|
| 323 |
+
<div style={itemsWrapStyle}>
|
| 324 |
+
{group.items.map((entry) => {
|
| 325 |
+
const rawIdx = filingDoc.toc.indexOf(entry);
|
| 326 |
+
const next = filingDoc.toc[rawIdx + 1];
|
| 327 |
+
const body = sliceSection(filingDoc.markdown, entry, next);
|
| 328 |
+
const itemOpen = openItems.has(entry.slug);
|
| 329 |
+
return (
|
| 330 |
+
<div key={entry.slug} style={itemCardStyle}>
|
| 331 |
+
<button
|
| 332 |
+
type="button"
|
| 333 |
+
onClick={() => toggleItem(entry.slug)}
|
| 334 |
+
aria-expanded={itemOpen}
|
| 335 |
+
style={itemHeaderStyle(itemOpen)}
|
| 336 |
+
>
|
| 337 |
+
<span style={headerLeftStyle(8)}>
|
| 338 |
+
<span style={chevronStyle(itemOpen)}>▸</span>
|
| 339 |
+
<span style={headerTitleStyle(13, 700, '#1e293b')}>
|
| 340 |
+
{entry.text}
|
| 341 |
+
</span>
|
| 342 |
+
</span>
|
| 343 |
+
<span style={headerBadgeStyle}>
|
| 344 |
+
{entry.charCount.toLocaleString()} chars
|
| 345 |
+
</span>
|
| 346 |
+
</button>
|
| 347 |
+
{itemOpen ? (
|
| 348 |
+
<div style={sectionBodyStyle}>
|
| 349 |
+
<FilingMarkdown markdown={body} />
|
| 350 |
+
</div>
|
| 351 |
+
) : null}
|
| 352 |
+
</div>
|
| 353 |
+
);
|
| 354 |
+
})}
|
| 355 |
+
</div>
|
| 356 |
+
) : null}
|
| 357 |
+
</div>
|
| 358 |
+
);
|
| 359 |
+
})}
|
| 360 |
+
</div>
|
| 361 |
+
)
|
| 362 |
+
) : null}
|
| 363 |
+
</div>
|
| 364 |
+
) : (
|
| 365 |
+
<div style={pickerHintStyle}>Select a filing above to read it with a collapsible section-by-section view.</div>
|
| 366 |
+
)}
|
| 367 |
+
</>
|
| 368 |
+
)}
|
| 369 |
+
</div>
|
| 370 |
+
);
|
| 371 |
+
};
|
| 372 |
+
|
| 373 |
+
// ── styles ─────────────────────────────────────────────────────────────────
|
| 374 |
+
|
| 375 |
+
const rootStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 };
|
| 376 |
+
const dimStyle: React.CSSProperties = { fontSize: 12, color: '#64748b' };
|
| 377 |
+
const errorStyle: React.CSSProperties = { fontSize: 12, color: '#b91c1c', padding: 8, background: '#fef2f2', borderRadius: 6 };
|
| 378 |
+
const formRowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' };
|
| 379 |
+
const formLabelStyle: React.CSSProperties = { fontSize: 11, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5 };
|
| 380 |
+
const selectStyle: React.CSSProperties = {
|
| 381 |
+
height: 32, borderRadius: 6, border: '1px solid #cbd5e1', background: '#fff',
|
| 382 |
+
padding: '0 10px', fontSize: 13, color: '#0f172a', outline: 'none',
|
| 383 |
+
};
|
| 384 |
+
const filingListStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 220, overflowY: 'auto' };
|
| 385 |
+
const filingRowStyle = (selected: boolean): React.CSSProperties => ({
|
| 386 |
+
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
| 387 |
+
border: `1px solid ${selected ? '#1d4ed8' : '#e2e8f0'}`,
|
| 388 |
+
borderRadius: 8, background: selected ? '#eff6ff' : '#ffffff', cursor: 'pointer',
|
| 389 |
+
});
|
| 390 |
+
const edgarLinkStyle: React.CSSProperties = { fontSize: 11, color: '#475569', textDecoration: 'none', flexShrink: 0 };
|
| 391 |
+
const readerWrapStyle: React.CSSProperties = {
|
| 392 |
+
display: 'flex', flexDirection: 'column', gap: 10,
|
| 393 |
+
borderTop: '1px solid #e2e8f0', paddingTop: 12, marginTop: 4,
|
| 394 |
+
};
|
| 395 |
+
const readerHeaderStyle: React.CSSProperties = {
|
| 396 |
+
display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'space-between', flexWrap: 'wrap',
|
| 397 |
+
};
|
| 398 |
+
const edgarPillStyle: React.CSSProperties = {
|
| 399 |
+
padding: '6px 12px', borderRadius: 999, background: '#1d4ed8', color: '#fff',
|
| 400 |
+
fontSize: 12, fontWeight: 700, textDecoration: 'none', flexShrink: 0,
|
| 401 |
+
};
|
| 402 |
+
const accordionStyle: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 10 };
|
| 403 |
+
// Part — outer container, heavier visual weight because it's a structural
|
| 404 |
+
// division (10-K Part I = Financial Info, Part II = Other Info).
|
| 405 |
+
const partCardStyle: React.CSSProperties = {
|
| 406 |
+
border: '1px solid #cbd5e1', borderRadius: 10, background: '#ffffff', overflow: 'hidden',
|
| 407 |
+
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
| 408 |
+
};
|
| 409 |
+
const partHeaderStyle = (open: boolean): React.CSSProperties => ({
|
| 410 |
+
width: '100%', border: 'none',
|
| 411 |
+
background: open ? 'linear-gradient(180deg, #f1f5f9 0%, #e2e8f0 100%)' : '#f8fafc',
|
| 412 |
+
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
| 413 |
+
justifyContent: 'space-between', cursor: 'pointer', textAlign: 'left',
|
| 414 |
+
});
|
| 415 |
+
const itemsWrapStyle: React.CSSProperties = {
|
| 416 |
+
display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 12px 12px',
|
| 417 |
+
background: '#f8fafc',
|
| 418 |
+
};
|
| 419 |
+
// Item — inner, lighter card.
|
| 420 |
+
const itemCardStyle: React.CSSProperties = {
|
| 421 |
+
border: '1px solid #e2e8f0', borderRadius: 8, background: '#ffffff', overflow: 'hidden',
|
| 422 |
+
};
|
| 423 |
+
const itemHeaderStyle = (open: boolean): React.CSSProperties => ({
|
| 424 |
+
width: '100%', border: 'none', background: open ? '#f1f5f9' : '#ffffff',
|
| 425 |
+
padding: '9px 12px', display: 'flex', alignItems: 'center', gap: 10,
|
| 426 |
+
justifyContent: 'space-between', cursor: 'pointer', textAlign: 'left',
|
| 427 |
+
});
|
| 428 |
+
const chevronStyle = (open: boolean): React.CSSProperties => ({
|
| 429 |
+
display: 'inline-block', transition: 'transform 0.15s', transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
| 430 |
+
color: '#64748b', fontSize: 12, flexShrink: 0, width: 12,
|
| 431 |
+
});
|
| 432 |
+
// Header left-hand column (chevron + title). Takes available space and can
|
| 433 |
+
// shrink below its intrinsic width — the `minWidth: 0` is what lets the
|
| 434 |
+
// nowrap+ellipsis truncation actually kick in on narrow viewports instead of
|
| 435 |
+
// the weird mid-word wrap we hit with ZETA ("Bus" / chars / "iness").
|
| 436 |
+
const headerLeftStyle = (gap: number): React.CSSProperties => ({
|
| 437 |
+
display: 'flex', alignItems: 'center', gap, minWidth: 0, flex: '1 1 auto',
|
| 438 |
+
});
|
| 439 |
+
// Title span itself — must carry `minWidth: 0` so the flex-shrink cascade
|
| 440 |
+
// reaches the span where `overflow: hidden` + `textOverflow: ellipsis`
|
| 441 |
+
// actually clip on narrow viewports.
|
| 442 |
+
const headerTitleStyle = (
|
| 443 |
+
fontSize: number,
|
| 444 |
+
fontWeight: number,
|
| 445 |
+
color: string,
|
| 446 |
+
): React.CSSProperties => ({
|
| 447 |
+
fontSize,
|
| 448 |
+
fontWeight,
|
| 449 |
+
color,
|
| 450 |
+
minWidth: 0,
|
| 451 |
+
overflow: 'hidden',
|
| 452 |
+
textOverflow: 'ellipsis',
|
| 453 |
+
whiteSpace: 'nowrap',
|
| 454 |
+
});
|
| 455 |
+
const headerBadgeStyle: React.CSSProperties = {
|
| 456 |
+
fontSize: 11, color: '#64748b', flexShrink: 0,
|
| 457 |
+
};
|
| 458 |
+
const sectionBodyStyle: React.CSSProperties = { padding: '4px 14px 14px' };
|
| 459 |
+
const pickerHintStyle: React.CSSProperties = {
|
| 460 |
+
fontSize: 12, color: '#64748b', padding: 12, textAlign: 'center',
|
| 461 |
+
background: '#f8fafc', border: '1px dashed #e2e8f0', borderRadius: 8,
|
| 462 |
+
};
|
| 463 |
+
const unsupportedStyle: React.CSSProperties = {
|
| 464 |
+
padding: 14, background: '#fffbeb', border: '1px solid #fde68a',
|
| 465 |
+
borderRadius: 8, display: 'flex', alignItems: 'center', gap: 12,
|
| 466 |
+
justifyContent: 'space-between', flexWrap: 'wrap',
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
export default SecFilings;
|
src/TerraFin/interface/frontend/src/stock/useStockData.ts
CHANGED
|
@@ -51,7 +51,44 @@ interface ChartPoint {
|
|
| 51 |
close: number;
|
| 52 |
}
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
export function useCompanyInfo(ticker: string, enabled = true) {
|
| 57 |
const [data, setData] = useState<CompanyInfo | null>(null);
|
|
@@ -127,6 +164,61 @@ export function useFinancials(ticker: string, statement: string, period: string,
|
|
| 127 |
return { data, loading, error };
|
| 128 |
}
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
export function useChartOHLCV(ticker: string) {
|
| 131 |
const [data, setData] = useState<ChartPoint[]>([]);
|
| 132 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 51 |
close: number;
|
| 52 |
}
|
| 53 |
|
| 54 |
+
interface FilingRow {
|
| 55 |
+
accession: string;
|
| 56 |
+
form: string;
|
| 57 |
+
filingDate: string;
|
| 58 |
+
reportDate: string | null;
|
| 59 |
+
primaryDocument: string;
|
| 60 |
+
primaryDocDescription: string | null;
|
| 61 |
+
indexUrl: string;
|
| 62 |
+
documentUrl: string;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
interface FilingsListResponse {
|
| 66 |
+
ticker: string;
|
| 67 |
+
cik: number;
|
| 68 |
+
forms: string[];
|
| 69 |
+
filings: FilingRow[];
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
interface TocEntry {
|
| 73 |
+
level: number;
|
| 74 |
+
text: string;
|
| 75 |
+
lineIndex: number;
|
| 76 |
+
slug: string;
|
| 77 |
+
charCount: number;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
interface FilingDocument {
|
| 81 |
+
ticker: string;
|
| 82 |
+
accession: string;
|
| 83 |
+
primaryDocument: string;
|
| 84 |
+
markdown: string;
|
| 85 |
+
toc: TocEntry[];
|
| 86 |
+
charCount: number;
|
| 87 |
+
indexUrl: string;
|
| 88 |
+
documentUrl: string;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export type { ChartPoint, CompanyInfo, EarningsRecord, FilingDocument, FilingRow, FilingsListResponse, FinancialRow, FinancialStatement, TocEntry };
|
| 92 |
|
| 93 |
export function useCompanyInfo(ticker: string, enabled = true) {
|
| 94 |
const [data, setData] = useState<CompanyInfo | null>(null);
|
|
|
|
| 164 |
return { data, loading, error };
|
| 165 |
}
|
| 166 |
|
| 167 |
+
export function useFilings(ticker: string, enabled = true) {
|
| 168 |
+
const [data, setData] = useState<FilingsListResponse | null>(null);
|
| 169 |
+
const [loading, setLoading] = useState(false);
|
| 170 |
+
const [error, setError] = useState<string | null>(null);
|
| 171 |
+
|
| 172 |
+
useEffect(() => {
|
| 173 |
+
if (!ticker || !enabled) {
|
| 174 |
+
setData(null);
|
| 175 |
+
setError(null);
|
| 176 |
+
setLoading(false);
|
| 177 |
+
return;
|
| 178 |
+
}
|
| 179 |
+
setLoading(true);
|
| 180 |
+
setError(null);
|
| 181 |
+
fetch(`/stock/api/filings?ticker=${encodeURIComponent(ticker)}`)
|
| 182 |
+
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`${res.status}`))))
|
| 183 |
+
.then((d: FilingsListResponse) => setData(d))
|
| 184 |
+
.catch((e) => setError(e.message))
|
| 185 |
+
.finally(() => setLoading(false));
|
| 186 |
+
}, [enabled, ticker]);
|
| 187 |
+
|
| 188 |
+
return { data, loading, error };
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
export function useFilingDocument(
|
| 192 |
+
ticker: string,
|
| 193 |
+
accession: string | null,
|
| 194 |
+
primaryDocument: string | null,
|
| 195 |
+
form: string,
|
| 196 |
+
enabled = true,
|
| 197 |
+
) {
|
| 198 |
+
const [data, setData] = useState<FilingDocument | null>(null);
|
| 199 |
+
const [loading, setLoading] = useState(false);
|
| 200 |
+
const [error, setError] = useState<string | null>(null);
|
| 201 |
+
|
| 202 |
+
useEffect(() => {
|
| 203 |
+
if (!enabled || !ticker || !accession || !primaryDocument) {
|
| 204 |
+
setData(null);
|
| 205 |
+
setError(null);
|
| 206 |
+
setLoading(false);
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
setLoading(true);
|
| 210 |
+
setError(null);
|
| 211 |
+
const qs = new URLSearchParams({ ticker, accession, primaryDocument, form });
|
| 212 |
+
fetch(`/stock/api/filing-document?${qs.toString()}`)
|
| 213 |
+
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`${res.status}`))))
|
| 214 |
+
.then((d: FilingDocument) => setData(d))
|
| 215 |
+
.catch((e) => setError(e.message))
|
| 216 |
+
.finally(() => setLoading(false));
|
| 217 |
+
}, [accession, enabled, form, primaryDocument, ticker]);
|
| 218 |
+
|
| 219 |
+
return { data, loading, error };
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
export function useChartOHLCV(ticker: string) {
|
| 223 |
const [data, setData] = useState<ChartPoint[]>([]);
|
| 224 |
const [loading, setLoading] = useState(true);
|
src/TerraFin/interface/stock/data_routes.py
CHANGED
|
@@ -11,6 +11,8 @@ from TerraFin.analytics.analysis.risk import estimate_beta_5y_monthly, estimate_
|
|
| 11 |
from TerraFin.interface.stock.payloads import (
|
| 12 |
build_company_info_payload,
|
| 13 |
build_earnings_payload,
|
|
|
|
|
|
|
| 14 |
build_financial_statement_payload,
|
| 15 |
resolve_ticker_query,
|
| 16 |
)
|
|
@@ -84,6 +86,54 @@ class ResolveTickerResponse(BaseModel):
|
|
| 84 |
path: str
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
class BetaEstimateResponse(BaseModel):
|
| 88 |
status: str
|
| 89 |
symbol: str
|
|
@@ -207,4 +257,29 @@ def create_stock_data_router() -> APIRouter:
|
|
| 207 |
def api_resolve_ticker(q: str = Query(..., min_length=1)):
|
| 208 |
return ResolveTickerResponse(**resolve_ticker_query(q))
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
return router
|
|
|
|
| 11 |
from TerraFin.interface.stock.payloads import (
|
| 12 |
build_company_info_payload,
|
| 13 |
build_earnings_payload,
|
| 14 |
+
build_filing_document_payload,
|
| 15 |
+
build_filings_list_payload,
|
| 16 |
build_financial_statement_payload,
|
| 17 |
resolve_ticker_query,
|
| 18 |
)
|
|
|
|
| 86 |
path: str
|
| 87 |
|
| 88 |
|
| 89 |
+
class FilingRowResponse(BaseModel):
|
| 90 |
+
accession: str
|
| 91 |
+
form: str
|
| 92 |
+
filingDate: str
|
| 93 |
+
reportDate: str | None = None
|
| 94 |
+
primaryDocument: str
|
| 95 |
+
primaryDocDescription: str | None = None
|
| 96 |
+
indexUrl: str
|
| 97 |
+
documentUrl: str
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class FilingLatestByFormEntry(BaseModel):
|
| 101 |
+
accession: str
|
| 102 |
+
primaryDocument: str
|
| 103 |
+
filingDate: str
|
| 104 |
+
reportDate: str | None = None
|
| 105 |
+
documentUrl: str
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class FilingsListResponse(BaseModel):
|
| 109 |
+
ticker: str
|
| 110 |
+
cik: int
|
| 111 |
+
forms: list[str]
|
| 112 |
+
filings: list[FilingRowResponse]
|
| 113 |
+
# Shortcut: `latestByForm["10-K"].accession` gives direct access to the
|
| 114 |
+
# newest filing of a given form without scanning the chronological list.
|
| 115 |
+
latestByForm: dict[str, FilingLatestByFormEntry] = {}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TocEntry(BaseModel):
|
| 119 |
+
level: int
|
| 120 |
+
text: str
|
| 121 |
+
lineIndex: int
|
| 122 |
+
slug: str
|
| 123 |
+
charCount: int
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class FilingDocumentResponse(BaseModel):
|
| 127 |
+
ticker: str
|
| 128 |
+
accession: str
|
| 129 |
+
primaryDocument: str
|
| 130 |
+
markdown: str
|
| 131 |
+
toc: list[TocEntry]
|
| 132 |
+
charCount: int
|
| 133 |
+
indexUrl: str
|
| 134 |
+
documentUrl: str
|
| 135 |
+
|
| 136 |
+
|
| 137 |
class BetaEstimateResponse(BaseModel):
|
| 138 |
status: str
|
| 139 |
symbol: str
|
|
|
|
| 257 |
def api_resolve_ticker(q: str = Query(..., min_length=1)):
|
| 258 |
return ResolveTickerResponse(**resolve_ticker_query(q))
|
| 259 |
|
| 260 |
+
@router.get(f"{STOCK_API_PREFIX}/filings", response_model=FilingsListResponse)
|
| 261 |
+
def api_filings(
|
| 262 |
+
ticker: str = Query(..., min_length=1),
|
| 263 |
+
limit: int = Query(default=20, ge=1, le=100),
|
| 264 |
+
):
|
| 265 |
+
return FilingsListResponse(**build_filings_list_payload(ticker, limit=limit))
|
| 266 |
+
|
| 267 |
+
@router.get(f"{STOCK_API_PREFIX}/filing-document", response_model=FilingDocumentResponse)
|
| 268 |
+
def api_filing_document(
|
| 269 |
+
ticker: str = Query(..., min_length=1),
|
| 270 |
+
accession: str = Query(..., min_length=1),
|
| 271 |
+
primaryDocument: str = Query(..., min_length=1),
|
| 272 |
+
form: str = Query(default="10-Q", min_length=1),
|
| 273 |
+
includeImages: bool = Query(default=False),
|
| 274 |
+
):
|
| 275 |
+
return FilingDocumentResponse(
|
| 276 |
+
**build_filing_document_payload(
|
| 277 |
+
ticker,
|
| 278 |
+
accession,
|
| 279 |
+
primaryDocument,
|
| 280 |
+
form=form,
|
| 281 |
+
include_images=includeImages,
|
| 282 |
+
)
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
return router
|
src/TerraFin/interface/stock/payloads.py
CHANGED
|
@@ -5,6 +5,14 @@ from typing import Any
|
|
| 5 |
from fastapi import HTTPException
|
| 6 |
|
| 7 |
from TerraFin.data import DataFactory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from TerraFin.data.providers.market.ticker_info import get_ticker_earnings, get_ticker_info
|
| 9 |
from TerraFin.interface.market_insights.payloads import canonical_macro_name, resolve_macro_type
|
| 10 |
|
|
@@ -119,3 +127,156 @@ def resolve_ticker_query(query: str) -> dict[str, str]:
|
|
| 119 |
|
| 120 |
upper = name.upper()
|
| 121 |
return {"type": "stock", "name": upper, "path": f"/stock/{upper}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from fastapi import HTTPException
|
| 6 |
|
| 7 |
from TerraFin.data import DataFactory
|
| 8 |
+
from TerraFin.data.providers.corporate.filings.sec_edgar import (
|
| 9 |
+
build_toc,
|
| 10 |
+
download_filing,
|
| 11 |
+
get_company_filings,
|
| 12 |
+
get_ticker_to_cik_dict_cached,
|
| 13 |
+
parse_sec_filing,
|
| 14 |
+
)
|
| 15 |
+
from TerraFin.data.providers.corporate.filings.sec_edgar.filing import SecEdgarError
|
| 16 |
from TerraFin.data.providers.market.ticker_info import get_ticker_earnings, get_ticker_info
|
| 17 |
from TerraFin.interface.market_insights.payloads import canonical_macro_name, resolve_macro_type
|
| 18 |
|
|
|
|
| 127 |
|
| 128 |
upper = name.upper()
|
| 129 |
return {"type": "stock", "name": upper, "path": f"/stock/{upper}"}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# SEC filings
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _edgar_urls(cik: int, accession_number: str, primary_doc: str) -> dict[str, str]:
|
| 138 |
+
acc_clean = accession_number.replace("-", "")
|
| 139 |
+
cik_padded = str(cik).zfill(10)
|
| 140 |
+
archive_base = f"https://www.sec.gov/Archives/edgar/data/{cik_padded}/{acc_clean}"
|
| 141 |
+
# Human-facing link: SEC's inline-XBRL viewer (`/ix?doc=/Archives/...`).
|
| 142 |
+
# This is the URL you get when you click a filing on EDGAR — it wraps the
|
| 143 |
+
# primary document in SEC's viewer UI with proper styling and navigation,
|
| 144 |
+
# working for both iXBRL-tagged and plain HTML filings.
|
| 145 |
+
# `indexUrl` still points at the raw directory listing for debugging and
|
| 146 |
+
# file-level access.
|
| 147 |
+
if primary_doc:
|
| 148 |
+
document_url = f"https://www.sec.gov/ix?doc=/Archives/edgar/data/{cik_padded}/{acc_clean}/{primary_doc}"
|
| 149 |
+
else:
|
| 150 |
+
document_url = f"{archive_base}/"
|
| 151 |
+
return {
|
| 152 |
+
"indexUrl": f"{archive_base}/",
|
| 153 |
+
"documentUrl": document_url,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _resolve_cik(ticker: str) -> int:
|
| 158 |
+
cik = get_ticker_to_cik_dict_cached().get(ticker.upper())
|
| 159 |
+
if cik is None:
|
| 160 |
+
raise HTTPException(status_code=404, detail=f"CIK not found for ticker '{ticker}'.")
|
| 161 |
+
return int(cik)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def build_filings_list_payload(ticker: str, *, limit: int = 20) -> dict[str, Any]:
|
| 165 |
+
"""List recent 10-K / 10-Q / 8-K filings with EDGAR links."""
|
| 166 |
+
normalized = ticker.upper()
|
| 167 |
+
cik = _resolve_cik(normalized)
|
| 168 |
+
try:
|
| 169 |
+
df = get_company_filings(cik, include_8k=True)
|
| 170 |
+
except SecEdgarError as exc:
|
| 171 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 172 |
+
|
| 173 |
+
if df is None or df.empty:
|
| 174 |
+
return {"ticker": normalized, "cik": cik, "forms": [], "filings": []}
|
| 175 |
+
|
| 176 |
+
df = df.sort_values("filingDate", ascending=False).head(limit)
|
| 177 |
+
|
| 178 |
+
filings: list[dict[str, Any]] = []
|
| 179 |
+
for _, row in df.iterrows():
|
| 180 |
+
accession = str(row.get("accessionNumber", ""))
|
| 181 |
+
primary_doc = str(row.get("primaryDocument", ""))
|
| 182 |
+
urls = _edgar_urls(cik, accession, primary_doc)
|
| 183 |
+
filings.append(
|
| 184 |
+
{
|
| 185 |
+
"accession": accession,
|
| 186 |
+
"form": str(row.get("form", "")),
|
| 187 |
+
"filingDate": str(row.get("filingDate", "")),
|
| 188 |
+
"reportDate": str(row.get("reportDate", "")) or None,
|
| 189 |
+
"primaryDocument": primary_doc,
|
| 190 |
+
"primaryDocDescription": str(row.get("primaryDocDescription", "")) or None,
|
| 191 |
+
"indexUrl": urls["indexUrl"],
|
| 192 |
+
"documentUrl": urls["documentUrl"],
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Forms present in this result, in canonical order (10-K first, then 10-Q, 8-K,
|
| 197 |
+
# then anything else). Lets the frontend build a form dropdown without
|
| 198 |
+
# guessing what exists for this ticker (e.g. 20-F for foreign filers).
|
| 199 |
+
seen: list[str] = []
|
| 200 |
+
for f in filings:
|
| 201 |
+
form = f["form"]
|
| 202 |
+
if form and form not in seen:
|
| 203 |
+
seen.append(form)
|
| 204 |
+
priority = {"10-K": 0, "10-K/A": 1, "10-Q": 2, "10-Q/A": 3, "8-K": 4, "8-K/A": 5}
|
| 205 |
+
seen.sort(key=lambda f: priority.get(f, 100))
|
| 206 |
+
|
| 207 |
+
# Per-form shortcut so an agent can do `latestByForm["10-K"]` directly
|
| 208 |
+
# instead of scanning a chronological list that mixes 8-Ks in front of
|
| 209 |
+
# quarterly/annual filings. Each entry carries enough to call
|
| 210 |
+
# `sec_filing_section` / `sec_filing_document` with no follow-up.
|
| 211 |
+
latest_by_form: dict[str, dict[str, Any]] = {}
|
| 212 |
+
for f in filings:
|
| 213 |
+
form = f["form"]
|
| 214 |
+
if form and form not in latest_by_form:
|
| 215 |
+
latest_by_form[form] = {
|
| 216 |
+
"accession": f["accession"],
|
| 217 |
+
"primaryDocument": f["primaryDocument"],
|
| 218 |
+
"filingDate": f["filingDate"],
|
| 219 |
+
"reportDate": f["reportDate"],
|
| 220 |
+
"documentUrl": f["documentUrl"],
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"ticker": normalized,
|
| 225 |
+
"cik": cik,
|
| 226 |
+
"forms": seen,
|
| 227 |
+
"filings": filings,
|
| 228 |
+
"latestByForm": latest_by_form,
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def build_filing_document_payload(
|
| 233 |
+
ticker: str,
|
| 234 |
+
accession: str,
|
| 235 |
+
primary_document: str,
|
| 236 |
+
*,
|
| 237 |
+
form: str = "10-Q",
|
| 238 |
+
include_images: bool = False,
|
| 239 |
+
) -> dict[str, Any]:
|
| 240 |
+
"""Fetch + parse a specific filing, returning the markdown body plus a TOC."""
|
| 241 |
+
normalized = ticker.upper()
|
| 242 |
+
cik = _resolve_cik(normalized)
|
| 243 |
+
|
| 244 |
+
acc_clean = accession.replace("-", "")
|
| 245 |
+
if not primary_document:
|
| 246 |
+
raise HTTPException(status_code=400, detail="primaryDocument is required.")
|
| 247 |
+
|
| 248 |
+
try:
|
| 249 |
+
html = download_filing(cik, acc_clean, primary_document)
|
| 250 |
+
except SecEdgarError as exc:
|
| 251 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 252 |
+
|
| 253 |
+
try:
|
| 254 |
+
markdown = parse_sec_filing(html, form, include_images=include_images)
|
| 255 |
+
except ValueError as exc:
|
| 256 |
+
# Unsupported form (e.g., a DEF 14A passed through) — raw fallback.
|
| 257 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 258 |
+
|
| 259 |
+
# `max_level=2` keeps the sidebar compact (Part/Item scaffold only).
|
| 260 |
+
# Normalize keys to camelCase for frontend consistency with the rest of the API.
|
| 261 |
+
toc = [
|
| 262 |
+
{
|
| 263 |
+
"level": entry["level"],
|
| 264 |
+
"text": entry["text"],
|
| 265 |
+
"lineIndex": entry["line_index"],
|
| 266 |
+
"slug": entry["slug"],
|
| 267 |
+
"charCount": entry["char_count"],
|
| 268 |
+
}
|
| 269 |
+
for entry in build_toc(markdown, max_level=2)
|
| 270 |
+
]
|
| 271 |
+
|
| 272 |
+
urls = _edgar_urls(cik, acc_clean, primary_document)
|
| 273 |
+
return {
|
| 274 |
+
"ticker": normalized,
|
| 275 |
+
"accession": acc_clean,
|
| 276 |
+
"primaryDocument": primary_document,
|
| 277 |
+
"markdown": markdown,
|
| 278 |
+
"toc": toc,
|
| 279 |
+
"charCount": len(markdown),
|
| 280 |
+
"indexUrl": urls["indexUrl"],
|
| 281 |
+
"documentUrl": urls["documentUrl"],
|
| 282 |
+
}
|
tests/agent/fixtures/aapl_10q_fy25q2_sample.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## PART I - FINANCIAL INFORMATION
|
| 2 |
+
|
| 3 |
+
## Item 1. Financial Statements
|
| 4 |
+
|
| 5 |
+
CONDENSED CONSOLIDATED STATEMENTS OF OPERATIONS (unaudited, in millions, except per share data)
|
| 6 |
+
|
| 7 |
+
| | Three Months Ended March 29, 2025 | Three Months Ended March 30, 2024 | Six Months Ended March 29, 2025 | Six Months Ended March 30, 2024 |
|
| 8 |
+
| --- | --- | --- | --- | --- |
|
| 9 |
+
| Products net sales | 68,714 | 66,886 | 166,674 | 163,582 |
|
| 10 |
+
| Services net sales | 26,645 | 23,867 | 52,086 | 46,984 |
|
| 11 |
+
| Total net sales | 95,359 | 90,753 | 218,760 | 210,566 |
|
| 12 |
+
| Products cost of sales | 45,598 | 43,757 | 108,905 | 106,557 |
|
| 13 |
+
| Services cost of sales | 6,723 | 6,280 | 12,819 | 11,926 |
|
| 14 |
+
| Total cost of sales | 52,321 | 50,037 | 121,724 | 118,483 |
|
| 15 |
+
| Gross margin | 43,038 | 40,716 | 97,036 | 92,083 |
|
| 16 |
+
| Operating expenses — Research and development | 8,550 | 7,903 | 16,850 | 15,668 |
|
| 17 |
+
| Operating expenses — Selling, general and administrative | 6,728 | 6,468 | 13,836 | 13,418 |
|
| 18 |
+
| Total operating expenses | 15,278 | 14,371 | 30,686 | 29,086 |
|
| 19 |
+
| Operating income | 27,760 | 26,345 | 66,350 | 62,997 |
|
| 20 |
+
| Other income/(expense), net | 308 | 158 | 515 | 208 |
|
| 21 |
+
| Income before provision for income taxes | 28,068 | 26,503 | 66,865 | 63,205 |
|
| 22 |
+
| Provision for income taxes | 3,477 | 4,422 | 10,697 | 10,551 |
|
| 23 |
+
| Net income | 24,591 | 22,081 | 56,168 | 52,654 |
|
| 24 |
+
| Earnings per share — basic | 1.65 | 1.42 | 3.75 | 3.39 |
|
| 25 |
+
| Earnings per share — diluted | 1.65 | 1.41 | 3.74 | 3.37 |
|
| 26 |
+
|
| 27 |
+
## Item 2. Management's Discussion and Analysis of Financial Condition and Results of Operations
|
| 28 |
+
|
| 29 |
+
### Products and Services Performance
|
| 30 |
+
|
| 31 |
+
iPhone net sales increased during Q2 2025 compared to Q2 2024 driven by the iPhone 16 family and higher average selling prices. Mac net sales increased primarily due to higher sales of MacBook Air following the M4 refresh. iPad net sales were roughly flat on a year-over-year basis. Wearables, Home and Accessories net sales decreased, reflecting softer demand for Apple Watch. Services net sales grew 12% year over year and reached an all-time high, driven by App Store, advertising, and subscription services.
|
| 32 |
+
|
| 33 |
+
### Gross Margin
|
| 34 |
+
|
| 35 |
+
Products gross margin was 33.6% versus 34.6% in the year-ago quarter, reflecting a less favorable mix and higher component costs. Services gross margin was 74.7% versus 74.6%, essentially flat. Company-wide gross margin was 45.1% versus 44.9%, improving slightly on Services mix.
|
| 36 |
+
|
| 37 |
+
### Operating Expenses
|
| 38 |
+
|
| 39 |
+
R&D spending grew 8% year over year, reflecting continued investment in Apple Intelligence and custom silicon. SG&A grew 4%, below the rate of net sales growth, indicating operating leverage.
|
| 40 |
+
|
| 41 |
+
### Liquidity and Capital Resources
|
| 42 |
+
|
| 43 |
+
Cash, cash equivalents and marketable securities totaled $140.8 billion at quarter end. During the first six months of fiscal 2025, the Company generated $63.8 billion in cash from operating activities. The Company returned $55.7 billion to shareholders in the first six months through $14.1 billion of dividends and $41.6 billion of share repurchases. The Company expects to continue its capital return program.
|
| 44 |
+
|
| 45 |
+
As of March 29, 2025, the Company had $95.3 billion of term debt outstanding, with a weighted average effective interest rate of 3.9%. $9.4 billion of term debt is scheduled to mature within the next 12 months.
|
| 46 |
+
|
| 47 |
+
## Item 3. Quantitative and Qualitative Disclosures About Market Risk
|
| 48 |
+
|
| 49 |
+
There have been no material changes in the Company's exposure to market risk since September 28, 2024. Refer to Part II, Item 7A of the Annual Report on Form 10-K for the fiscal year ended September 28, 2024.
|
| 50 |
+
|
| 51 |
+
## Item 4. Controls and Procedures
|
| 52 |
+
|
| 53 |
+
Based on an evaluation, management concluded that the Company's disclosure controls and procedures were effective as of March 29, 2025. There were no changes in the Company's internal control over financial reporting during the quarter that materially affected, or are reasonably likely to materially affect, internal control over financial reporting.
|
| 54 |
+
|
| 55 |
+
## PART II - OTHER INFORMATION
|
| 56 |
+
|
| 57 |
+
## Item 1. Legal Proceedings
|
| 58 |
+
|
| 59 |
+
Refer to Note 10, "Commitments and Contingencies," of the Notes to Condensed Consolidated Financial Statements for a description of the Company's legal proceedings. The Company is currently subject to multiple legal and regulatory matters, including antitrust investigations by the European Commission and the U.S. Department of Justice relating to App Store practices. On April 30, 2025, the U.S. District Court for the Northern District of California issued findings adverse to the Company in the Epic Games matter. The Company intends to appeal.
|
| 60 |
+
|
| 61 |
+
## Item 1A. Risk Factors
|
| 62 |
+
|
| 63 |
+
The Company's business, reputation, results of operations and financial condition are subject to various risks, many of which are not exclusively within the Company's control. The risks and uncertainties described in Part I, Item 1A of the Annual Report on Form 10-K for the fiscal year ended September 28, 2024, remain materially unchanged. Additional risks not currently known or that are currently considered immaterial may also impact the Company's business.
|
| 64 |
+
|
| 65 |
+
## Item 2. Unregistered Sales of Equity Securities and Use of Proceeds
|
| 66 |
+
|
| 67 |
+
During the second quarter of fiscal 2025, the Company repurchased $20.9 billion of its common stock. The repurchase program authorized in May 2024 had $81.1 billion remaining as of March 29, 2025.
|
| 68 |
+
|
| 69 |
+
## Item 5. Other Information
|
| 70 |
+
|
| 71 |
+
During the three months ended March 29, 2025, none of the Company's directors or officers adopted or terminated a Rule 10b5-1 trading arrangement or a non-Rule 10b5-1 trading arrangement.
|
| 72 |
+
|
| 73 |
+
## Item 6. Exhibits
|
| 74 |
+
|
| 75 |
+
Reference is made to the Exhibit Index included herewith.
|
tests/agent/fixtures/nvda_10q_fy26q3_sample.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## PART I - FINANCIAL INFORMATION
|
| 2 |
+
|
| 3 |
+
## Item 1. Financial Statements
|
| 4 |
+
|
| 5 |
+
CONDENSED CONSOLIDATED STATEMENTS OF INCOME (unaudited, in millions, except per share data)
|
| 6 |
+
|
| 7 |
+
| | Three Months Ended October 26, 2025 | Three Months Ended October 27, 2024 | Nine Months Ended October 26, 2025 | Nine Months Ended October 27, 2024 |
|
| 8 |
+
| --- | --- | --- | --- | --- |
|
| 9 |
+
| Revenue | 45,217 | 35,082 | 126,441 | 90,840 |
|
| 10 |
+
| Cost of revenue | 11,856 | 9,150 | 33,412 | 23,901 |
|
| 11 |
+
| Gross profit | 33,361 | 25,932 | 93,029 | 66,939 |
|
| 12 |
+
| Research and development | 4,612 | 3,390 | 13,184 | 9,518 |
|
| 13 |
+
| Sales, general and administrative | 1,098 | 896 | 3,012 | 2,558 |
|
| 14 |
+
| Operating income | 27,651 | 21,646 | 76,833 | 54,863 |
|
| 15 |
+
| Interest income | 623 | 471 | 1,845 | 1,289 |
|
| 16 |
+
| Interest expense | (61) | (63) | (183) | (188) |
|
| 17 |
+
| Other, net | 57 | (11) | 142 | (18) |
|
| 18 |
+
| Income before income tax | 28,270 | 22,043 | 78,637 | 55,946 |
|
| 19 |
+
| Income tax expense | 4,529 | 3,053 | 12,267 | 7,845 |
|
| 20 |
+
| Net income | 23,741 | 18,990 | 66,370 | 48,101 |
|
| 21 |
+
| Earnings per share — basic | 0.97 | 0.78 | 2.71 | 1.97 |
|
| 22 |
+
| Earnings per share — diluted | 0.96 | 0.77 | 2.69 | 1.94 |
|
| 23 |
+
|
| 24 |
+
Segment revenue (unaudited, in millions):
|
| 25 |
+
|
| 26 |
+
| | Three Months Ended Oct 26, 2025 | Three Months Ended Oct 27, 2024 | Nine Months Ended Oct 26, 2025 | Nine Months Ended Oct 27, 2024 |
|
| 27 |
+
| --- | --- | --- | --- | --- |
|
| 28 |
+
| Data Center | 39,542 | 30,771 | 109,875 | 77,313 |
|
| 29 |
+
| Gaming | 3,205 | 3,279 | 9,410 | 8,412 |
|
| 30 |
+
| Professional Visualization | 601 | 486 | 1,763 | 1,346 |
|
| 31 |
+
| Automotive | 592 | 449 | 1,706 | 1,181 |
|
| 32 |
+
| OEM & Other | 1,277 | 97 | 3,687 | 2,588 |
|
| 33 |
+
|
| 34 |
+
## Item 2. Management's Discussion and Analysis of Financial Condition and Results of Operations
|
| 35 |
+
|
| 36 |
+
### Data Center
|
| 37 |
+
|
| 38 |
+
Data Center revenue of $39.5 billion for Q3 FY26 was up 29% sequentially and up 29% year over year. Growth was driven by ramp of Blackwell platform systems, which generated more than $28 billion of revenue during the quarter, and continued demand from large cloud service providers. Enterprise contribution was approximately 15% of Data Center revenue, up from roughly 10% in the prior-year quarter. Networking within Data Center grew to approximately $6 billion quarterly revenue, driven by Spectrum-X Ethernet and NVLink switching.
|
| 39 |
+
|
| 40 |
+
### Gaming
|
| 41 |
+
|
| 42 |
+
Gaming revenue of $3.2 billion was down 2% year over year, reflecting slower supply of Blackwell-generation GeForce products that shipped late in the quarter. Management expects Gaming to return to year-over-year growth in Q4 FY26 as supply normalizes.
|
| 43 |
+
|
| 44 |
+
### Gross Margin
|
| 45 |
+
|
| 46 |
+
GAAP gross margin for Q3 FY26 was 73.8%, down 120 basis points sequentially and up 140 basis points year over year. The sequential decline reflects higher initial yield costs on the transition to Blackwell HGX-based systems with more complex packaging and integration. Management expects gross margin to stabilize in the mid-70s as yields improve and the product mix benefits from higher-margin NVLink-based systems.
|
| 47 |
+
|
| 48 |
+
### Operating Expenses
|
| 49 |
+
|
| 50 |
+
Operating expenses grew to $5.7 billion, up approximately 33% year over year, reflecting higher compensation expense for an expanded engineering workforce, multi-generation roadmap execution (Blackwell Ultra and Rubin), and build-out of data-center networking capabilities. Full-year FY26 non-GAAP operating expense growth is expected in the high-30% range.
|
| 51 |
+
|
| 52 |
+
### Liquidity and Capital Resources
|
| 53 |
+
|
| 54 |
+
Cash, cash equivalents and marketable securities totaled $38.5 billion at quarter end, up from $34.8 billion at the end of Q2 FY26. The Company generated $26.8 billion of cash from operating activities in Q3, and $71.2 billion in the first nine months. Capital expenditures were $1.4 billion for Q3 and $3.7 billion year to date, reflecting investments in data-center build-out, including internal compute capacity for engineering and product development.
|
| 55 |
+
|
| 56 |
+
During Q3, the Company repurchased 125.3 million shares of common stock for $22.4 billion. An additional $1.8 billion in dividends was paid. As of October 26, 2025, approximately $29.6 billion remained available under the Company's share-repurchase authorization. The Board has authorized share repurchases without an expiration date, and repurchases are made from time to time based on market conditions.
|
| 57 |
+
|
| 58 |
+
The Company has $8.46 billion in long-term debt outstanding with interest rates ranging from 1.55% to 3.70%.
|
| 59 |
+
|
| 60 |
+
## Item 3. Quantitative and Qualitative Disclosures About Market Risk
|
| 61 |
+
|
| 62 |
+
Our exposure to market risk has not changed materially since January 26, 2025. Refer to Part II, Item 7A of our Annual Report on Form 10-K for the fiscal year ended January 26, 2025.
|
| 63 |
+
|
| 64 |
+
## Item 4. Controls and Procedures
|
| 65 |
+
|
| 66 |
+
Based on an evaluation, management concluded that the Company's disclosure controls and procedures were effective as of October 26, 2025. There were no changes in the Company's internal control over financial reporting during the quarter that materially affected, or are reasonably likely to materially affect, internal control over financial reporting.
|
| 67 |
+
|
| 68 |
+
## PART II - OTHER INFORMATION
|
| 69 |
+
|
| 70 |
+
## Item 1. Legal Proceedings
|
| 71 |
+
|
| 72 |
+
Refer to Note 13, "Commitments and Contingencies," of the Notes to Condensed Consolidated Financial Statements for a description of the Company's legal proceedings. The Company is subject to various class-action lawsuits in the United States alleging violations of federal securities laws with respect to disclosures relating to the AI accelerator business. Additionally, the Company is subject to export-licensing requirements imposed by the U.S. Department of Commerce, and on September 12, 2025, additional licensing requirements were published that further restrict sales of certain GPU products to customers in specified regions. The Company does not currently believe that these restrictions, individually or in the aggregate, will have a material adverse effect on its results of operations for the remainder of fiscal 2026, given visible order substitution.
|
| 73 |
+
|
| 74 |
+
## Item 1A. Risk Factors
|
| 75 |
+
|
| 76 |
+
The Company's business, reputation, results of operations and financial condition are subject to various risks, many of which are not exclusively within the Company's control. Except as noted below, the risks and uncertainties described in Part I, Item 1A of the Annual Report on Form 10-K for the fiscal year ended January 26, 2025, remain materially unchanged.
|
| 77 |
+
|
| 78 |
+
Export controls: On September 12, 2025, the U.S. government published additional export-licensing requirements that further restrict sales of certain GPU products. The Company continues to assess the impact and may need to redesign products for compliance, which could delay product availability and reduce revenue from affected customers. For the quarter ended October 26, 2025, the Company recorded $1.2 billion of revenue from products covered by the new restrictions, substantially all of which was recognized prior to the restrictions becoming effective.
|
| 79 |
+
|
| 80 |
+
## Item 2. Unregistered Sales of Equity Securities and Use of Proceeds
|
| 81 |
+
|
| 82 |
+
During the third quarter of fiscal 2026, the Company repurchased 125.3 million shares of common stock for $22.4 billion. As of October 26, 2025, approximately $29.6 billion remained available under the share-repurchase program.
|
| 83 |
+
|
| 84 |
+
## Item 5. Other Information
|
| 85 |
+
|
| 86 |
+
During the three months ended October 26, 2025, one of our officers, Colette M. Kress (Executive Vice President and Chief Financial Officer), adopted a Rule 10b5-1 trading arrangement on September 11, 2025 providing for the potential sale of up to 200,000 shares of common stock until September 11, 2026. No director or other officer adopted or terminated a Rule 10b5-1 trading arrangement or a non-Rule 10b5-1 trading arrangement.
|
| 87 |
+
|
| 88 |
+
## Item 6. Exhibits
|
| 89 |
+
|
| 90 |
+
Reference is made to the Exhibit Index included herewith.
|
tests/agent/fixtures/tsla_10q_2025q2_sample.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## PART I - FINANCIAL INFORMATION
|
| 2 |
+
|
| 3 |
+
## Item 1. Financial Statements
|
| 4 |
+
|
| 5 |
+
CONDENSED CONSOLIDATED STATEMENTS OF OPERATIONS (unaudited, in millions, except per share data)
|
| 6 |
+
|
| 7 |
+
| | Three Months Ended June 30, 2025 | Three Months Ended June 30, 2024 | Six Months Ended June 30, 2025 | Six Months Ended June 30, 2024 |
|
| 8 |
+
| --- | --- | --- | --- | --- |
|
| 9 |
+
| Automotive sales | 17,894 | 19,878 | 34,961 | 38,873 |
|
| 10 |
+
| Automotive regulatory credits | 439 | 890 | 1,034 | 1,332 |
|
| 11 |
+
| Automotive leasing | 472 | 458 | 969 | 934 |
|
| 12 |
+
| Total automotive revenues | 18,805 | 21,226 | 36,964 | 41,139 |
|
| 13 |
+
| Energy generation and storage | 2,856 | 3,014 | 5,523 | 4,649 |
|
| 14 |
+
| Services and other | 2,892 | 2,608 | 5,657 | 5,071 |
|
| 15 |
+
| Total revenues | 24,553 | 26,848 | 48,144 | 50,859 |
|
| 16 |
+
| Automotive cost of revenues | 15,842 | 17,376 | 31,144 | 33,857 |
|
| 17 |
+
| Energy cost of revenues | 2,183 | 2,285 | 4,243 | 3,628 |
|
| 18 |
+
| Services cost of revenues | 2,648 | 2,519 | 5,267 | 4,828 |
|
| 19 |
+
| Total cost of revenues | 20,673 | 22,180 | 40,654 | 42,313 |
|
| 20 |
+
| Gross profit | 3,880 | 4,668 | 7,490 | 8,546 |
|
| 21 |
+
| Research and development | 1,584 | 1,074 | 2,852 | 2,225 |
|
| 22 |
+
| Selling, general and administrative | 1,439 | 1,277 | 2,806 | 2,655 |
|
| 23 |
+
| Restructuring and other | — | 622 | 50 | 1,164 |
|
| 24 |
+
| Total operating expenses | 3,023 | 2,973 | 5,708 | 6,044 |
|
| 25 |
+
| Income from operations | 857 | 1,695 | 1,782 | 2,502 |
|
| 26 |
+
| Interest income | 324 | 348 | 672 | 698 |
|
| 27 |
+
| Interest expense | (54) | (86) | (109) | (168) |
|
| 28 |
+
| Other income/(expense), net | 237 | (30) | 425 | 98 |
|
| 29 |
+
| Income before income taxes | 1,364 | 1,927 | 2,770 | 3,130 |
|
| 30 |
+
| Provision for income taxes | 172 | 364 | 381 | 775 |
|
| 31 |
+
| Net income | 1,192 | 1,563 | 2,389 | 2,355 |
|
| 32 |
+
| Earnings per share — basic | 0.37 | 0.49 | 0.74 | 0.74 |
|
| 33 |
+
| Earnings per share — diluted | 0.37 | 0.48 | 0.74 | 0.72 |
|
| 34 |
+
|
| 35 |
+
## Item 2. Management's Discussion and Analysis of Financial Condition and Results of Operations
|
| 36 |
+
|
| 37 |
+
### Overview
|
| 38 |
+
|
| 39 |
+
Total revenues for the three months ended June 30, 2025 decreased 9% year over year to $24.55 billion, primarily reflecting lower vehicle average selling prices and reduced automotive regulatory credit revenue. Deliveries for the quarter were approximately 384,000 vehicles, down 13% year over year, driven by softer demand in key markets and the production changeover at the Fremont and Austin factories to support the refreshed Model Y. Energy generation and storage revenue declined 5% as storage deployments timing shifted into the second half of the year, partially offset by continued Megapack pricing strength.
|
| 40 |
+
|
| 41 |
+
### Automotive Gross Margin (excluding regulatory credits)
|
| 42 |
+
|
| 43 |
+
Automotive gross margin excluding regulatory credits was 14.9% compared with 14.6% in the year-ago quarter. The improvement reflects $1,100 per vehicle of cost reduction driven by lower component costs and manufacturing efficiency, partially offset by lower average selling prices of approximately $1,700 per vehicle. Regulatory credit revenue of $439 million compared with $890 million in the prior-year quarter reflects a contraction in credit demand from U.S. original equipment manufacturers.
|
| 44 |
+
|
| 45 |
+
### Energy Storage
|
| 46 |
+
|
| 47 |
+
Energy storage deployments were 9.4 GWh compared with 9.4 GWh in Q2 2024. Megapack gross margin expanded approximately 500 basis points to 33.7%. Management expects full-year storage deployments of 40-50 GWh versus 31.4 GWh in 2024.
|
| 48 |
+
|
| 49 |
+
### Operating Expenses
|
| 50 |
+
|
| 51 |
+
R&D expense increased 47% year over year to $1.58 billion, reflecting accelerated investment in AI training infrastructure (Cortex supercomputer cluster now at approximately 50,000 H100-equivalent GPUs) and continued development of the next-generation vehicle platform. SG&A grew 13%. There were no material restructuring charges in the current quarter compared with $622 million in Q2 2024.
|
| 52 |
+
|
| 53 |
+
### Liquidity and Capital Resources
|
| 54 |
+
|
| 55 |
+
Cash, cash equivalents, and short-term investments totaled $36.8 billion as of June 30, 2025, up from $30.7 billion at December 31, 2024. The Company generated $3.6 billion of operating cash flow during Q2 2025 and $6.3 billion for the six months ended June 30, 2025. Capital expenditures were $2.3 billion for the quarter and $4.5 billion year to date, including Cortex cluster build-out, production capacity for the Model Y refresh, and Gigafactory Shanghai storage expansion.
|
| 56 |
+
|
| 57 |
+
The Company does not pay dividends and has no share-repurchase program.
|
| 58 |
+
|
| 59 |
+
Long-term debt net of current portion was $5.4 billion as of June 30, 2025, at a weighted average interest rate of 2.4%.
|
| 60 |
+
|
| 61 |
+
## Item 3. Quantitative and Qualitative Disclosures About Market Risk
|
| 62 |
+
|
| 63 |
+
There have been no material changes in our market risk exposures since December 31, 2024. Refer to Part II, Item 7A of our Annual Report on Form 10-K for the fiscal year ended December 31, 2024.
|
| 64 |
+
|
| 65 |
+
## Item 4. Controls and Procedures
|
| 66 |
+
|
| 67 |
+
Based on an evaluation, management concluded that the Company's disclosure controls and procedures were effective as of June 30, 2025. There were no changes in the Company's internal control over financial reporting during the quarter that materially affected, or are reasonably likely to materially affect, internal control over financial reporting.
|
| 68 |
+
|
| 69 |
+
## PART II - OTHER INFORMATION
|
| 70 |
+
|
| 71 |
+
## Item 1. Legal Proceedings
|
| 72 |
+
|
| 73 |
+
Refer to Note 12, "Commitments and Contingencies," of the Notes to Condensed Consolidated Financial Statements for a description of the Company's legal proceedings. The Company is subject to various lawsuits concerning product-liability matters related to Autopilot and Full Self-Driving features, and in June 2025 the National Highway Traffic Safety Administration opened a formal investigation into alleged phantom-braking incidents affecting approximately 1.9 million Model 3 and Model Y vehicles produced between 2017 and 2025. The Company continues to cooperate with NHTSA and other regulators.
|
| 74 |
+
|
| 75 |
+
## Item 1A. Risk Factors
|
| 76 |
+
|
| 77 |
+
Except as noted below, the risks and uncertainties described in Part I, Item 1A of the Annual Report on Form 10-K for the fiscal year ended December 31, 2024, remain materially unchanged.
|
| 78 |
+
|
| 79 |
+
Tariff exposure: Effective April 3, 2025, the U.S. government imposed tariffs on imported automotive components, which has increased per-vehicle production cost by approximately $1,500 for certain imported powertrain parts. The Company has taken steps to mitigate this exposure through supplier diversification and may not be able to fully offset the impact through pricing.
|
| 80 |
+
|
| 81 |
+
## Item 2. Unregistered Sales of Equity Securities and Use of Proceeds
|
| 82 |
+
|
| 83 |
+
The Company does not have an authorized share-repurchase program, and no shares were repurchased during the quarter.
|
| 84 |
+
|
| 85 |
+
## Item 5. Other Information
|
| 86 |
+
|
| 87 |
+
During the three months ended June 30, 2025, none of our directors or officers adopted or terminated a Rule 10b5-1 trading arrangement or a non-Rule 10b5-1 trading arrangement.
|
| 88 |
+
|
| 89 |
+
## Item 6. Exhibits
|
| 90 |
+
|
| 91 |
+
Reference is made to the Exhibit Index included herewith.
|
tests/agent/test_runtime.py
CHANGED
|
@@ -121,6 +121,43 @@ class _FakeService:
|
|
| 121 |
"processing": _processing(),
|
| 122 |
}
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
class _ExplodingService(_FakeService):
|
| 126 |
def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
|
|
@@ -160,10 +197,24 @@ def test_default_capability_registry_contains_kernel_capabilities() -> None:
|
|
| 160 |
"economic",
|
| 161 |
"macro_focus",
|
| 162 |
"calendar_events",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
"open_chart",
|
| 164 |
"fundamental_screen",
|
| 165 |
"risk_profile",
|
| 166 |
"valuation",
|
|
|
|
|
|
|
|
|
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
|
|
| 121 |
"processing": _processing(),
|
| 122 |
}
|
| 123 |
|
| 124 |
+
def sec_filings(self, ticker: str) -> dict[str, object]:
|
| 125 |
+
return {"ticker": ticker, "cik": 1, "forms": [], "filings": [], "processing": _processing()}
|
| 126 |
+
|
| 127 |
+
def sec_filing_document(
|
| 128 |
+
self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
|
| 129 |
+
) -> dict[str, object]:
|
| 130 |
+
return {"ticker": ticker, "accession": accession, "primaryDocument": primaryDocument, "toc": [], "charCount": 0, "indexUrl": "", "documentUrl": "", "processing": _processing()}
|
| 131 |
+
|
| 132 |
+
def sec_filing_section(
|
| 133 |
+
self, ticker: str, accession: str, primaryDocument: str, sectionSlug: str, *, form: str = "10-Q"
|
| 134 |
+
) -> dict[str, object]:
|
| 135 |
+
return {"ticker": ticker, "accession": accession, "sectionSlug": sectionSlug, "sectionTitle": "stub", "markdown": "", "charCount": 0, "documentUrl": "", "processing": _processing()}
|
| 136 |
+
|
| 137 |
+
def fear_greed(self) -> dict[str, object]:
|
| 138 |
+
return {"score": 50, "rating": "Neutral", "processing": _processing()}
|
| 139 |
+
|
| 140 |
+
def sp500_dcf(self) -> dict[str, object]:
|
| 141 |
+
return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
|
| 142 |
+
|
| 143 |
+
def beta_estimate(self, ticker: str) -> dict[str, object]:
|
| 144 |
+
return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
|
| 145 |
+
|
| 146 |
+
def top_companies(self) -> dict[str, object]:
|
| 147 |
+
return {"companies": [], "count": 0, "processing": _processing()}
|
| 148 |
+
|
| 149 |
+
def market_regime(self) -> dict[str, object]:
|
| 150 |
+
return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
|
| 151 |
+
|
| 152 |
+
def trailing_forward_pe(self) -> dict[str, object]:
|
| 153 |
+
return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
|
| 154 |
+
|
| 155 |
+
def market_breadth(self) -> dict[str, object]:
|
| 156 |
+
return {"metrics": [], "processing": _processing()}
|
| 157 |
+
|
| 158 |
+
def watchlist(self) -> dict[str, object]:
|
| 159 |
+
return {"items": [], "count": 0, "processing": _processing()}
|
| 160 |
+
|
| 161 |
|
| 162 |
class _ExplodingService(_FakeService):
|
| 163 |
def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
|
|
|
|
| 197 |
"economic",
|
| 198 |
"macro_focus",
|
| 199 |
"calendar_events",
|
| 200 |
+
# Dashboard widget-parity capabilities, inserted before `open_chart` so
|
| 201 |
+
# registry ordering tracks grouping (research read-only first, then
|
| 202 |
+
# chart-opening, then SEC filings).
|
| 203 |
+
"fear_greed",
|
| 204 |
+
"sp500_dcf",
|
| 205 |
+
"beta_estimate",
|
| 206 |
+
"top_companies",
|
| 207 |
+
"market_regime",
|
| 208 |
+
"trailing_forward_pe",
|
| 209 |
+
"market_breadth",
|
| 210 |
+
"watchlist",
|
| 211 |
"open_chart",
|
| 212 |
"fundamental_screen",
|
| 213 |
"risk_profile",
|
| 214 |
"valuation",
|
| 215 |
+
"sec_filings",
|
| 216 |
+
"sec_filing_document",
|
| 217 |
+
"sec_filing_section",
|
| 218 |
)
|
| 219 |
|
| 220 |
|
tests/agent/test_sec_capabilities.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the three SEC-filing capabilities wired into the agent runtime."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from TerraFin.agent.runtime import build_default_capability_registry
|
| 6 |
+
from TerraFin.agent.service import TerraFinAgentService
|
| 7 |
+
from TerraFin.agent.tool_contracts import HOSTED_TOOL_CONTRACTS
|
| 8 |
+
from TerraFin.interface.stock import payloads
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture(autouse=True)
|
| 12 |
+
def _stub_payloads(monkeypatch):
|
| 13 |
+
"""Intercept network-facing payload builders with deterministic stubs."""
|
| 14 |
+
monkeypatch.setattr(payloads, "get_ticker_to_cik_dict_cached", lambda: {"AAPL": 320193})
|
| 15 |
+
|
| 16 |
+
def fake_filings_df(cik, include_8k=False):
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
return pd.DataFrame(
|
| 20 |
+
{
|
| 21 |
+
"form": ["10-K", "10-Q", "8-K"],
|
| 22 |
+
"accessionNumber": ["0000320193-25-000001", "0000320193-24-000010", "0000320193-24-000007"],
|
| 23 |
+
"filingDate": ["2025-02-02", "2024-11-02", "2024-08-02"],
|
| 24 |
+
"reportDate": ["2024-12-28", "2024-09-28", "2024-08-01"],
|
| 25 |
+
"primaryDocument": ["aapl-20241228.htm", "aapl-20240928.htm", "aapl-8k.htm"],
|
| 26 |
+
"primaryDocDescription": ["10-K", "10-Q", "8-K"],
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
monkeypatch.setattr(payloads, "get_company_filings", fake_filings_df)
|
| 31 |
+
monkeypatch.setattr(payloads, "download_filing", lambda cik, acc, doc: "<html>irrelevant</html>")
|
| 32 |
+
fake_md = (
|
| 33 |
+
"## PART I - FINANCIAL INFORMATION\n\n"
|
| 34 |
+
"## Item 1. Business\n\n"
|
| 35 |
+
"Apple designs, manufactures, and markets smartphones.\n"
|
| 36 |
+
"Revenue grew 5% year over year driven by services growth.\n\n"
|
| 37 |
+
"## Item 7. MD&A\n\n"
|
| 38 |
+
"Liquidity remained strong with operating cash flow of $110B.\n"
|
| 39 |
+
)
|
| 40 |
+
monkeypatch.setattr(payloads, "parse_sec_filing", lambda html, form, *, include_images=False: fake_md)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_capability_registry_exposes_three_sec_tools() -> None:
|
| 44 |
+
registry = build_default_capability_registry()
|
| 45 |
+
names = registry.names()
|
| 46 |
+
for expected in ("sec_filings", "sec_filing_document", "sec_filing_section"):
|
| 47 |
+
assert expected in names, f"{expected} should be registered"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_tool_contracts_registered_for_every_sec_capability() -> None:
|
| 51 |
+
for expected in ("sec_filings", "sec_filing_document", "sec_filing_section"):
|
| 52 |
+
assert expected in HOSTED_TOOL_CONTRACTS, f"{expected} missing a tool contract"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_sec_filings_returns_list_with_edgar_links() -> None:
|
| 56 |
+
svc = TerraFinAgentService()
|
| 57 |
+
result = svc.sec_filings("AAPL")
|
| 58 |
+
|
| 59 |
+
assert result["ticker"] == "AAPL"
|
| 60 |
+
assert result["cik"] == 320193
|
| 61 |
+
assert len(result["filings"]) == 3
|
| 62 |
+
assert all("documentUrl" in f and "indexUrl" in f for f in result["filings"])
|
| 63 |
+
# iXBRL viewer wrapper lands on the primary document.
|
| 64 |
+
assert "sec.gov/ix?doc=" in result["filings"][0]["documentUrl"]
|
| 65 |
+
assert result["processing"]["sourceVersion"] == "sec-filings-list"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_sec_filing_document_returns_toc_without_markdown() -> None:
|
| 69 |
+
svc = TerraFinAgentService()
|
| 70 |
+
result = svc.sec_filing_document(
|
| 71 |
+
"AAPL",
|
| 72 |
+
accession="0000320193-25-000001",
|
| 73 |
+
primaryDocument="aapl-20241228.htm",
|
| 74 |
+
form="10-K",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
assert "markdown" not in result, "document-level tool must NOT return the full body"
|
| 78 |
+
assert [e["text"] for e in result["toc"]] == [
|
| 79 |
+
"PART I - FINANCIAL INFORMATION",
|
| 80 |
+
"Item 1. Business",
|
| 81 |
+
"Item 7. MD&A",
|
| 82 |
+
]
|
| 83 |
+
assert result["charCount"] > 0
|
| 84 |
+
assert result["processing"]["sourceVersion"] == "sec-filing-document"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_sec_filing_section_returns_only_target_section_body() -> None:
|
| 88 |
+
svc = TerraFinAgentService()
|
| 89 |
+
result = svc.sec_filing_section(
|
| 90 |
+
"AAPL",
|
| 91 |
+
accession="0000320193-25-000001",
|
| 92 |
+
primaryDocument="aapl-20241228.htm",
|
| 93 |
+
sectionSlug="item-1-business",
|
| 94 |
+
form="10-K",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert result["sectionSlug"] == "item-1-business"
|
| 98 |
+
assert result["sectionTitle"] == "Item 1. Business"
|
| 99 |
+
assert "Apple designs" in result["markdown"]
|
| 100 |
+
# MUST stop at the next heading — Item 7 prose must not leak in.
|
| 101 |
+
assert "Liquidity" not in result["markdown"]
|
| 102 |
+
assert result["charCount"] == len(result["markdown"])
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_sec_filing_section_raises_lookup_error_for_unknown_slug() -> None:
|
| 106 |
+
svc = TerraFinAgentService()
|
| 107 |
+
with pytest.raises(LookupError, match="not found"):
|
| 108 |
+
svc.sec_filing_section(
|
| 109 |
+
"AAPL",
|
| 110 |
+
accession="0000320193-25-000001",
|
| 111 |
+
primaryDocument="aapl-20241228.htm",
|
| 112 |
+
sectionSlug="does-not-exist",
|
| 113 |
+
form="10-K",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_sec_filing_section_error_includes_retry_hint_and_full_slug_list() -> None:
|
| 118 |
+
"""The error message must give the LLM everything it needs to retry
|
| 119 |
+
without giving up: explicit 'do NOT report not exist', the 5 largest
|
| 120 |
+
slugs with sizes (as a size-based fallback when name matching fails),
|
| 121 |
+
and the full slug list. Otherwise the agent reads 'not found' as a
|
| 122 |
+
dead end and tells the user the section doesn't exist — which is
|
| 123 |
+
exactly the failure mode this fix targets."""
|
| 124 |
+
svc = TerraFinAgentService()
|
| 125 |
+
try:
|
| 126 |
+
svc.sec_filing_section(
|
| 127 |
+
"AAPL",
|
| 128 |
+
accession="0000320193-25-000001",
|
| 129 |
+
primaryDocument="aapl-20241228.htm",
|
| 130 |
+
sectionSlug="financial-statements", # agent's common guess
|
| 131 |
+
form="10-K",
|
| 132 |
+
)
|
| 133 |
+
except LookupError as exc:
|
| 134 |
+
msg = str(exc)
|
| 135 |
+
else:
|
| 136 |
+
raise AssertionError("Expected LookupError for unknown slug.")
|
| 137 |
+
|
| 138 |
+
# Retry directive — the LLM must not treat this as a dead end.
|
| 139 |
+
assert "Do NOT report" in msg or "retry" in msg.lower()
|
| 140 |
+
# 5-largest hint — this is how the agent recovers when Item 7 / Item 8
|
| 141 |
+
# aren't in the TOC under their expected names.
|
| 142 |
+
assert "largest" in msg.lower() or "chars" in msg
|
| 143 |
+
# Full slug list so the agent can self-correct without a second TOC fetch.
|
| 144 |
+
assert "part-i" in msg
|
| 145 |
+
assert "item-1-business" in msg
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_sec_filing_section_stops_at_raw_toc_entry_not_next_content_heading() -> None:
|
| 149 |
+
"""Body bounds come from the TOC's own lineIndex list — no scanning markdown
|
| 150 |
+
for heading-looking text, which would false-match inline `##` tokens."""
|
| 151 |
+
svc = TerraFinAgentService()
|
| 152 |
+
# Item 1 Business → next toc entry is Item 7 MD&A; Liquidity section must be absent.
|
| 153 |
+
result = svc.sec_filing_section(
|
| 154 |
+
"AAPL",
|
| 155 |
+
accession="0000320193-25-000001",
|
| 156 |
+
primaryDocument="aapl-20241228.htm",
|
| 157 |
+
sectionSlug="item-1-business",
|
| 158 |
+
form="10-K",
|
| 159 |
+
)
|
| 160 |
+
assert "Revenue grew 5%" in result["markdown"]
|
| 161 |
+
assert "operating cash flow" not in result["markdown"]
|
tests/agent/test_service.py
CHANGED
|
@@ -73,6 +73,32 @@ class _FakePrivateDataService:
|
|
| 73 |
def get_market_breadth(self):
|
| 74 |
return [{"label": "Advancers", "value": "320", "tone": "#047857"}]
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def get_calendar_events(self, *, year: int, month: int, categories=None, limit=None):
|
| 77 |
_ = categories, limit
|
| 78 |
return [
|
|
@@ -150,8 +176,15 @@ def test_market_snapshot_and_calendar_include_processing(monkeypatch) -> None:
|
|
| 150 |
calendar = service.calendar_events(year=2026, month=4, categories="macro")
|
| 151 |
|
| 152 |
assert snapshot["processing"]["resolvedDepth"] == "recent"
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
assert calendar["processing"]["resolvedDepth"] == "full"
|
| 156 |
assert calendar["count"] == 1
|
| 157 |
|
|
@@ -176,3 +209,198 @@ def test_economic_normalizes_human_friendly_indicator_aliases(monkeypatch) -> No
|
|
| 176 |
|
| 177 |
assert "Federal Funds Effective Rate" in payload["indicators"]
|
| 178 |
assert "SOMA" in payload["indicators"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def get_market_breadth(self):
|
| 74 |
return [{"label": "Advancers", "value": "320", "tone": "#047857"}]
|
| 75 |
|
| 76 |
+
def get_fear_greed_current(self):
|
| 77 |
+
return {
|
| 78 |
+
"score": 42,
|
| 79 |
+
"rating": "Fear",
|
| 80 |
+
"previousClose": 45,
|
| 81 |
+
"oneWeekAgo": 50,
|
| 82 |
+
"oneMonthAgo": 38,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
def get_top_companies(self):
|
| 86 |
+
return [
|
| 87 |
+
{"symbol": "AAPL", "name": "Apple", "marketCap": 3_200_000_000_000},
|
| 88 |
+
{"symbol": "MSFT", "name": "Microsoft", "marketCap": 3_100_000_000_000},
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
def get_trailing_forward_pe(self):
|
| 92 |
+
return {
|
| 93 |
+
"date": "2026-04-15",
|
| 94 |
+
"summary": {"trailing_forward_pe_spread": 2.1},
|
| 95 |
+
"coverage": {"usable": 480, "requested": 500},
|
| 96 |
+
"history": [
|
| 97 |
+
{"date": "2026-04-14", "spread": 2.0},
|
| 98 |
+
{"date": "2026-04-15", "spread": 2.1},
|
| 99 |
+
],
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
def get_calendar_events(self, *, year: int, month: int, categories=None, limit=None):
|
| 103 |
_ = categories, limit
|
| 104 |
return [
|
|
|
|
| 176 |
calendar = service.calendar_events(year=2026, month=4, categories="macro")
|
| 177 |
|
| 178 |
assert snapshot["processing"]["resolvedDepth"] == "recent"
|
| 179 |
+
# market_breadth and watchlist are now standalone capabilities —
|
| 180 |
+
# `market_snapshot` is per-ticker only (was mixing whole-market state
|
| 181 |
+
# with per-ticker view, audit: DA Med-7).
|
| 182 |
+
assert "market_breadth" not in snapshot
|
| 183 |
+
assert "watchlist" not in snapshot
|
| 184 |
+
breadth = service.market_breadth()
|
| 185 |
+
watchlist = service.watchlist()
|
| 186 |
+
assert breadth["metrics"][0]["label"] == "Advancers"
|
| 187 |
+
assert watchlist["items"][0]["symbol"] == "AAPL"
|
| 188 |
assert calendar["processing"]["resolvedDepth"] == "full"
|
| 189 |
assert calendar["count"] == 1
|
| 190 |
|
|
|
|
| 209 |
|
| 210 |
assert "Federal Funds Effective Rate" in payload["indicators"]
|
| 211 |
assert "SOMA" in payload["indicators"]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
# DA-audit mismatch fixes — the agent must see the same payload shape the
|
| 216 |
+
# frontend widgets/routes render, so questions like "what's the Fear & Greed
|
| 217 |
+
# score?" or "what's the S&P 500 implied growth?" answer the same way whether
|
| 218 |
+
# the user looks at the widget or asks the agent. Before these fixes, agent
|
| 219 |
+
# had either no tool (widgets invisible to agent) or a cherry-picked subset
|
| 220 |
+
# (divergent between UI and agent).
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def test_fear_greed_returns_full_widget_payload(monkeypatch) -> None:
|
| 225 |
+
monkeypatch.setattr(agent_service, "get_private_data_service", lambda: _FakePrivateDataService())
|
| 226 |
+
service = agent_service.TerraFinAgentService()
|
| 227 |
+
|
| 228 |
+
payload = service.fear_greed()
|
| 229 |
+
|
| 230 |
+
assert payload["score"] == 42
|
| 231 |
+
assert payload["rating"] == "Fear"
|
| 232 |
+
assert payload["previousClose"] == 45
|
| 233 |
+
assert payload["oneWeekAgo"] == 50
|
| 234 |
+
assert payload["oneMonthAgo"] == 38
|
| 235 |
+
assert payload["processing"]["resolvedDepth"] == "full"
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def test_top_companies_returns_full_list(monkeypatch) -> None:
|
| 239 |
+
monkeypatch.setattr(agent_service, "get_private_data_service", lambda: _FakePrivateDataService())
|
| 240 |
+
service = agent_service.TerraFinAgentService()
|
| 241 |
+
|
| 242 |
+
payload = service.top_companies()
|
| 243 |
+
|
| 244 |
+
assert payload["count"] == 2
|
| 245 |
+
assert payload["companies"][0]["symbol"] == "AAPL"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_market_regime_mirrors_route_placeholder() -> None:
|
| 249 |
+
"""Route returns a placeholder; agent mirrors it verbatim so the two
|
| 250 |
+
views never diverge (even for placeholder content)."""
|
| 251 |
+
service = agent_service.TerraFinAgentService()
|
| 252 |
+
|
| 253 |
+
payload = service.market_regime()
|
| 254 |
+
|
| 255 |
+
assert "selective risk-taking" in payload["summary"]
|
| 256 |
+
assert payload["confidence"] == "low"
|
| 257 |
+
assert len(payload["signals"]) == 3
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_trailing_forward_pe_returns_dashboard_shape(monkeypatch) -> None:
|
| 261 |
+
monkeypatch.setattr(agent_service, "get_private_data_service", lambda: _FakePrivateDataService())
|
| 262 |
+
service = agent_service.TerraFinAgentService()
|
| 263 |
+
|
| 264 |
+
payload = service.trailing_forward_pe()
|
| 265 |
+
|
| 266 |
+
assert payload["date"] == "2026-04-15"
|
| 267 |
+
assert payload["latestValue"] == 2.1
|
| 268 |
+
assert payload["usableCount"] == 480
|
| 269 |
+
assert payload["requestedCount"] == 500
|
| 270 |
+
assert len(payload["history"]) == 2
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def test_market_breadth_standalone_returns_metrics_list(monkeypatch) -> None:
|
| 274 |
+
"""Was bundled inside market_snapshot; now a standalone tool so agent
|
| 275 |
+
and the MarketBreadthCard widget query the same data (DA Med-7)."""
|
| 276 |
+
monkeypatch.setattr(agent_service, "get_private_data_service", lambda: _FakePrivateDataService())
|
| 277 |
+
service = agent_service.TerraFinAgentService()
|
| 278 |
+
|
| 279 |
+
payload = service.market_breadth()
|
| 280 |
+
|
| 281 |
+
assert payload["metrics"][0]["label"] == "Advancers"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def test_watchlist_standalone_returns_items_list(monkeypatch) -> None:
|
| 285 |
+
monkeypatch.setattr(agent_service, "get_watchlist_service", lambda: _FakeWatchlistService())
|
| 286 |
+
service = agent_service.TerraFinAgentService()
|
| 287 |
+
|
| 288 |
+
payload = service.watchlist()
|
| 289 |
+
|
| 290 |
+
assert payload["count"] == 1
|
| 291 |
+
assert payload["items"][0]["symbol"] == "AAPL"
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_market_snapshot_no_longer_bundles_whole_market_widgets(monkeypatch) -> None:
|
| 295 |
+
"""Regression guard for DA Med-7 fix: market_snapshot is per-ticker
|
| 296 |
+
only. The temptation to re-bundle market-wide widgets for
|
| 297 |
+
"convenience" is what caused the original UI↔agent divergence —
|
| 298 |
+
widgets refresh on their own cadence while market_snapshot bundled a
|
| 299 |
+
point-in-time copy, so the agent's number could lag the UI's."""
|
| 300 |
+
monkeypatch.setattr(agent_service, "DataFactory", _FakeDataFactory)
|
| 301 |
+
service = agent_service.TerraFinAgentService()
|
| 302 |
+
|
| 303 |
+
snapshot = service.market_snapshot("TEST")
|
| 304 |
+
|
| 305 |
+
assert "market_breadth" not in snapshot
|
| 306 |
+
assert "watchlist" not in snapshot
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def test_portfolio_exposes_top_holdings_matching_route_sort(monkeypatch) -> None:
|
| 310 |
+
"""Route pre-computes `topHoldings` (top 8 by % of Portfolio). Agent
|
| 311 |
+
must return the same list so the UI treemap and the agent agree on
|
| 312 |
+
which positions are the "top" ones (DA Med-9)."""
|
| 313 |
+
import pandas as _pd
|
| 314 |
+
|
| 315 |
+
class _FakePortfolio:
|
| 316 |
+
def __init__(self) -> None:
|
| 317 |
+
self.df = _pd.DataFrame(
|
| 318 |
+
[
|
| 319 |
+
{"Stock": "AAPL", "% of Portfolio": 25.0, "Recent Activity": "Hold", "Updated": "2026-03-31"},
|
| 320 |
+
{"Stock": "MSFT", "% of Portfolio": 15.0, "Recent Activity": "Buy", "Updated": "2026-03-31"},
|
| 321 |
+
{"Stock": "NVDA", "% of Portfolio": 10.0, "Recent Activity": "Buy", "Updated": "2026-03-31"},
|
| 322 |
+
]
|
| 323 |
+
)
|
| 324 |
+
self.info = {"guru": "buffett"}
|
| 325 |
+
|
| 326 |
+
monkeypatch.setattr(agent_service, "get_portfolio_data", lambda _g: _FakePortfolio())
|
| 327 |
+
service = agent_service.TerraFinAgentService()
|
| 328 |
+
|
| 329 |
+
payload = service.portfolio("buffett")
|
| 330 |
+
|
| 331 |
+
assert payload["count"] == 3
|
| 332 |
+
assert len(payload["topHoldings"]) == 3
|
| 333 |
+
# Sorted by % of Portfolio descending.
|
| 334 |
+
assert payload["topHoldings"][0]["Stock"] == "AAPL"
|
| 335 |
+
assert payload["topHoldings"][1]["Stock"] == "MSFT"
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def test_valuation_passes_through_full_dcf_and_reverse_dcf_payloads(monkeypatch) -> None:
|
| 339 |
+
"""Regression for DA High-1 / High-2: agent must see the same
|
| 340 |
+
DCFValuationResponse shape the user sees in DcfValuationPanel —
|
| 341 |
+
scenarios, sensitivity, methods, rateCurve, dataQuality, not just
|
| 342 |
+
a cherry-picked 4-field subset."""
|
| 343 |
+
dcf_full = {
|
| 344 |
+
"status": "ready",
|
| 345 |
+
"currentIntrinsicValue": 120.0,
|
| 346 |
+
"upsidePct": 8.0,
|
| 347 |
+
"assumptions": {"discountRate": 0.1},
|
| 348 |
+
"scenarios": {"base": {}, "bull": {}, "bear": {}},
|
| 349 |
+
"sensitivity": [[1.1, 1.2], [0.9, 1.0]],
|
| 350 |
+
"methods": ["dcf", "pe"],
|
| 351 |
+
"rateCurve": [{"year": 2025, "rate": 0.05}],
|
| 352 |
+
"dataQuality": {"grade": "A"},
|
| 353 |
+
"warnings": [],
|
| 354 |
+
}
|
| 355 |
+
reverse_full = {
|
| 356 |
+
"status": "ready",
|
| 357 |
+
"impliedGrowthPct": 10.5,
|
| 358 |
+
"modelPrice": 115.0,
|
| 359 |
+
"projectedCashFlows": [10, 11, 12],
|
| 360 |
+
"growthProfile": {"terminalGrowth": 0.025},
|
| 361 |
+
"priceToCashFlowMultiple": 12.0,
|
| 362 |
+
"terminalValue": 1000.0,
|
| 363 |
+
"terminalGrowthPct": 2.5,
|
| 364 |
+
"discountSpreadPct": 3.0,
|
| 365 |
+
"rateCurve": [{"year": 2025, "rate": 0.05}],
|
| 366 |
+
"dataQuality": {"grade": "A"},
|
| 367 |
+
"warnings": [],
|
| 368 |
+
}
|
| 369 |
+
monkeypatch.setattr(agent_service, "build_stock_dcf_payload", lambda _t: dcf_full)
|
| 370 |
+
monkeypatch.setattr(agent_service, "build_stock_reverse_dcf_payload", lambda _t: reverse_full)
|
| 371 |
+
monkeypatch.setattr(
|
| 372 |
+
agent_service,
|
| 373 |
+
"build_company_info_payload",
|
| 374 |
+
lambda _t: {"currentPrice": 110.0, "trailingPE": 22.0, "forwardPE": 20.0, "trailingEps": 5.0},
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
class _FakeDF:
|
| 378 |
+
def __init__(self) -> None:
|
| 379 |
+
self._data = {"TotalStockholdersEquity": [2_000_000_000], "SharesOutstanding": [20_000_000]}
|
| 380 |
+
self.columns = list(self._data.keys())
|
| 381 |
+
self.empty = False
|
| 382 |
+
|
| 383 |
+
def __getitem__(self, col):
|
| 384 |
+
return type("_S", (), {"iloc": type("_I", (), {"__getitem__": lambda _, idx: self._data[col][idx]})()})()
|
| 385 |
+
|
| 386 |
+
class _FactoryStub:
|
| 387 |
+
def __init__(self, *args, **kwargs) -> None:
|
| 388 |
+
_ = args, kwargs
|
| 389 |
+
|
| 390 |
+
def get_corporate_data(self, *args, **kwargs):
|
| 391 |
+
_ = args, kwargs
|
| 392 |
+
return _FakeDF()
|
| 393 |
+
|
| 394 |
+
monkeypatch.setattr(agent_service, "DataFactory", _FactoryStub)
|
| 395 |
+
service = agent_service.TerraFinAgentService()
|
| 396 |
+
|
| 397 |
+
payload = service.valuation("AAPL")
|
| 398 |
+
|
| 399 |
+
# Full DCF payload preserved, not cherry-picked.
|
| 400 |
+
assert payload["dcf"]["scenarios"]["base"] == {}
|
| 401 |
+
assert payload["dcf"]["sensitivity"] == [[1.1, 1.2], [0.9, 1.0]]
|
| 402 |
+
assert payload["dcf"]["dataQuality"]["grade"] == "A"
|
| 403 |
+
# Full reverse DCF payload preserved.
|
| 404 |
+
assert payload["reverseDcf"]["projectedCashFlows"] == [10, 11, 12]
|
| 405 |
+
assert payload["reverseDcf"]["terminalGrowthPct"] == 2.5
|
| 406 |
+
assert payload["reverseDcf"]["priceToCashFlowMultiple"] == 12.0
|
tests/agent/test_tools.py
CHANGED
|
@@ -128,6 +128,67 @@ class _FakeService:
|
|
| 128 |
"processing": _processing(),
|
| 129 |
}
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
class _RetryingFakeService(_FakeService):
|
| 133 |
def __init__(self) -> None:
|
|
@@ -283,6 +344,69 @@ def test_run_tool_retries_with_repaired_macro_alias_before_exposing_error() -> N
|
|
| 283 |
assert service.calls == ["NASDAQ COMPOSITE", "Nasdaq"]
|
| 284 |
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
def test_run_tool_returns_internal_retryable_error_result_for_symbol_resolution_failures() -> None:
|
| 287 |
adapter = _adapter(service=_UnrepairableRecoverableErrorService())
|
| 288 |
session = adapter.runtime.create_session(DEFAULT_HOSTED_AGENT_NAME, session_id="tool:recoverable-error")
|
|
|
|
| 128 |
"processing": _processing(),
|
| 129 |
}
|
| 130 |
|
| 131 |
+
def sec_filings(self, ticker: str) -> dict[str, object]:
|
| 132 |
+
return {"ticker": ticker, "cik": 1, "forms": ["10-K"], "filings": [], "processing": _processing()}
|
| 133 |
+
|
| 134 |
+
def sec_filing_document(
|
| 135 |
+
self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
|
| 136 |
+
) -> dict[str, object]:
|
| 137 |
+
return {
|
| 138 |
+
"ticker": ticker,
|
| 139 |
+
"accession": accession,
|
| 140 |
+
"primaryDocument": primaryDocument,
|
| 141 |
+
"toc": [],
|
| 142 |
+
"charCount": 0,
|
| 143 |
+
"indexUrl": "",
|
| 144 |
+
"documentUrl": "",
|
| 145 |
+
"processing": _processing(),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
def sec_filing_section(
|
| 149 |
+
self,
|
| 150 |
+
ticker: str,
|
| 151 |
+
accession: str,
|
| 152 |
+
primaryDocument: str,
|
| 153 |
+
sectionSlug: str,
|
| 154 |
+
*,
|
| 155 |
+
form: str = "10-Q",
|
| 156 |
+
) -> dict[str, object]:
|
| 157 |
+
return {
|
| 158 |
+
"ticker": ticker,
|
| 159 |
+
"accession": accession,
|
| 160 |
+
"sectionSlug": sectionSlug,
|
| 161 |
+
"sectionTitle": "stub",
|
| 162 |
+
"markdown": "",
|
| 163 |
+
"charCount": 0,
|
| 164 |
+
"documentUrl": "",
|
| 165 |
+
"processing": _processing(),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
def fear_greed(self) -> dict[str, object]:
|
| 169 |
+
return {"score": 50, "rating": "Neutral", "processing": _processing()}
|
| 170 |
+
|
| 171 |
+
def sp500_dcf(self) -> dict[str, object]:
|
| 172 |
+
return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
|
| 173 |
+
|
| 174 |
+
def beta_estimate(self, ticker: str) -> dict[str, object]:
|
| 175 |
+
return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
|
| 176 |
+
|
| 177 |
+
def top_companies(self) -> dict[str, object]:
|
| 178 |
+
return {"companies": [], "count": 0, "processing": _processing()}
|
| 179 |
+
|
| 180 |
+
def market_regime(self) -> dict[str, object]:
|
| 181 |
+
return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
|
| 182 |
+
|
| 183 |
+
def trailing_forward_pe(self) -> dict[str, object]:
|
| 184 |
+
return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
|
| 185 |
+
|
| 186 |
+
def market_breadth(self) -> dict[str, object]:
|
| 187 |
+
return {"metrics": [], "processing": _processing()}
|
| 188 |
+
|
| 189 |
+
def watchlist(self) -> dict[str, object]:
|
| 190 |
+
return {"items": [], "count": 0, "processing": _processing()}
|
| 191 |
+
|
| 192 |
|
| 193 |
class _RetryingFakeService(_FakeService):
|
| 194 |
def __init__(self) -> None:
|
|
|
|
| 344 |
assert service.calls == ["NASDAQ COMPOSITE", "Nasdaq"]
|
| 345 |
|
| 346 |
|
| 347 |
+
class _SecFilingSlugNotFoundService(_FakeService):
|
| 348 |
+
"""Service fake where sec_filing_section raises the exact LookupError
|
| 349 |
+
shape the real service emits when the slug isn't in the TOC."""
|
| 350 |
+
|
| 351 |
+
def sec_filing_section(
|
| 352 |
+
self,
|
| 353 |
+
ticker: str,
|
| 354 |
+
accession: str,
|
| 355 |
+
primaryDocument: str,
|
| 356 |
+
sectionSlug: str,
|
| 357 |
+
*,
|
| 358 |
+
form: str = "10-Q",
|
| 359 |
+
) -> dict[str, object]:
|
| 360 |
+
raise LookupError(
|
| 361 |
+
f"Section '{sectionSlug}' not found. "
|
| 362 |
+
"Do NOT report 'section doesn't exist' — retry this tool with one of the available "
|
| 363 |
+
"slugs. The 5 largest sections in this filing are: "
|
| 364 |
+
"item-6-reserved (213068 chars, 'Item 6. Reserved.'), "
|
| 365 |
+
"item-1-business (180091 chars, 'Item 1. Business.'), "
|
| 366 |
+
"part-ii (218055 chars, 'Part II'), "
|
| 367 |
+
"part-i (185218 chars, 'PART I'), "
|
| 368 |
+
"item-3-legal-proceedings (4203 chars, 'Item 3. Legal Proceedings.'). "
|
| 369 |
+
"All 7 available slugs: part-i, item-1-business, item-2-properties, "
|
| 370 |
+
"item-3-legal-proceedings, part-ii, item-5-market, item-6-reserved"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_run_tool_classifies_sec_filing_section_bad_slug_as_retryable() -> None:
|
| 375 |
+
"""The `sec_filing_section` service raises a rich LookupError with
|
| 376 |
+
the full TOC slug list and explicit retry guidance. Without the
|
| 377 |
+
classifier recognizing it, the exception propagates raw — the model
|
| 378 |
+
sees an unstructured error, paraphrases it, and gives up instead
|
| 379 |
+
of retrying with a valid slug (the exact ZETA 10-K failure mode).
|
| 380 |
+
|
| 381 |
+
Regression for QA-identified CRITICAL #2."""
|
| 382 |
+
adapter = _adapter(service=_SecFilingSlugNotFoundService())
|
| 383 |
+
session = adapter.runtime.create_session(DEFAULT_HOSTED_AGENT_NAME, session_id="tool:sec-slug-not-found")
|
| 384 |
+
|
| 385 |
+
result = adapter.run_tool(
|
| 386 |
+
session.session.session_id,
|
| 387 |
+
"sec_filing_section",
|
| 388 |
+
{
|
| 389 |
+
"ticker": "ZETA",
|
| 390 |
+
"accession": "0000000000-26-000000",
|
| 391 |
+
"primaryDocument": "zeta.htm",
|
| 392 |
+
"sectionSlug": "financial-statements", # bad guess, not in TOC
|
| 393 |
+
"form": "10-K",
|
| 394 |
+
},
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
assert result.is_error is True
|
| 398 |
+
assert result.retryable is True
|
| 399 |
+
assert result.error_code == "sec_filing_section_slug_not_found"
|
| 400 |
+
assert result.payload["accepted"] is False
|
| 401 |
+
assert result.payload["error"]["retryable"] is True
|
| 402 |
+
# Model hint must tell the LLM to retry and include the full slug list.
|
| 403 |
+
hint = result.payload["error"]["modelHint"]
|
| 404 |
+
assert "DO NOT tell the user" in hint
|
| 405 |
+
assert "item-6-reserved" in hint # largest slug surfaced
|
| 406 |
+
assert "part-ii" in hint # Part II reference for 10-K earnings guidance
|
| 407 |
+
assert "MD&A" in hint or "earnings" in hint # earnings/MD&A hint
|
| 408 |
+
|
| 409 |
+
|
| 410 |
def test_run_tool_returns_internal_retryable_error_result_for_symbol_resolution_failures() -> None:
|
| 411 |
adapter = _adapter(service=_UnrepairableRecoverableErrorService())
|
| 412 |
session = adapter.runtime.create_session(DEFAULT_HOSTED_AGENT_NAME, session_id="tool:recoverable-error")
|
tests/interface/test_stock_filings_api.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import pytest
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
from TerraFin.interface.stock import payloads
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _stub_filings_df() -> pd.DataFrame:
|
| 9 |
+
return pd.DataFrame(
|
| 10 |
+
{
|
| 11 |
+
"form": ["10-K", "10-Q", "10-Q/A", "8-K", "DEF 14A"],
|
| 12 |
+
"accessionNumber": ["0000320193-25-000001", "0000320193-24-000010", "0000320193-24-000008", "0000320193-24-000007", "0000320193-24-000006"],
|
| 13 |
+
"filingDate": ["2025-02-02", "2024-11-02", "2024-08-10", "2024-08-02", "2024-05-01"],
|
| 14 |
+
"reportDate": ["2024-12-28", "2024-09-28", "2024-06-28", "2024-08-01", ""],
|
| 15 |
+
"primaryDocument": ["aapl-20241228.htm", "aapl-20240928.htm", "aapl-20240628a.htm", "aapl-8k.htm", "def14a.htm"],
|
| 16 |
+
"primaryDocDescription": ["10-K", "10-Q", "10-Q/A", "8-K", "DEF 14A"],
|
| 17 |
+
}
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def cik_mapping(monkeypatch):
|
| 23 |
+
monkeypatch.setattr(payloads, "get_ticker_to_cik_dict_cached", lambda: {"AAPL": 320193})
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_build_filings_list_payload_returns_filings_with_edgar_links(monkeypatch, cik_mapping) -> None:
|
| 27 |
+
monkeypatch.setattr(payloads, "get_company_filings", lambda cik, include_8k=False: _stub_filings_df())
|
| 28 |
+
|
| 29 |
+
result = payloads.build_filings_list_payload("AAPL")
|
| 30 |
+
|
| 31 |
+
assert result["ticker"] == "AAPL"
|
| 32 |
+
assert result["cik"] == 320193
|
| 33 |
+
assert len(result["filings"]) == 5
|
| 34 |
+
# Newest-first ordering preserved.
|
| 35 |
+
assert result["filings"][0]["filingDate"] == "2025-02-02"
|
| 36 |
+
# documentUrl uses SEC's inline-XBRL viewer so the click opens the rendered
|
| 37 |
+
# filing (styled, navigable) rather than the raw HTML. indexUrl still points
|
| 38 |
+
# at the directory for debugging / file-level access.
|
| 39 |
+
first = result["filings"][0]
|
| 40 |
+
assert first["indexUrl"] == "https://www.sec.gov/Archives/edgar/data/0000320193/000032019325000001/"
|
| 41 |
+
assert first["documentUrl"] == (
|
| 42 |
+
"https://www.sec.gov/ix?doc=/Archives/edgar/data/0000320193/000032019325000001/aapl-20241228.htm"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_build_filings_list_payload_prioritizes_forms_for_frontend_dropdown(monkeypatch, cik_mapping) -> None:
|
| 47 |
+
monkeypatch.setattr(payloads, "get_company_filings", lambda cik, include_8k=False: _stub_filings_df())
|
| 48 |
+
|
| 49 |
+
result = payloads.build_filings_list_payload("AAPL")
|
| 50 |
+
|
| 51 |
+
# Frontend dropdown gets 10-K first, 10-Q next, amendments grouped with parent
|
| 52 |
+
# priority, other forms at the end. Amendment appears after its parent form.
|
| 53 |
+
assert result["forms"][:4] == ["10-K", "10-Q", "10-Q/A", "8-K"]
|
| 54 |
+
assert "DEF 14A" in result["forms"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_build_filings_list_payload_surfaces_latestByForm_even_when_buried(monkeypatch, cik_mapping) -> None:
|
| 58 |
+
"""Agents otherwise scan the flat list and give up when 8-Ks cluster on top.
|
| 59 |
+
Verify `latestByForm` offers a direct lookup regardless of chronological order."""
|
| 60 |
+
import pandas as pd
|
| 61 |
+
|
| 62 |
+
# 8-Ks cluster on top in chronological order — 10-K is position 2, 10-Q position 3.
|
| 63 |
+
eight_k_heavy = pd.DataFrame(
|
| 64 |
+
{
|
| 65 |
+
"form": ["8-K", "8-K", "10-K", "8-K", "10-Q"],
|
| 66 |
+
"accessionNumber": ["a1", "a2", "a3", "a4", "a5"],
|
| 67 |
+
"filingDate": ["2026-04-10", "2026-04-02", "2026-02-05", "2026-02-04", "2025-10-30"],
|
| 68 |
+
"reportDate": ["2026-04-07", "2026-03-30", "2025-12-31", "2026-02-04", "2025-09-30"],
|
| 69 |
+
"primaryDocument": ["a1.htm", "a2.htm", "a3.htm", "a4.htm", "a5.htm"],
|
| 70 |
+
"primaryDocDescription": ["8-K", "8-K", "10-K", "8-K", "10-Q"],
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
monkeypatch.setattr(payloads, "get_company_filings", lambda cik, include_8k=False: eight_k_heavy)
|
| 74 |
+
|
| 75 |
+
result = payloads.build_filings_list_payload("AAPL")
|
| 76 |
+
|
| 77 |
+
latest = result["latestByForm"]
|
| 78 |
+
assert latest["10-K"]["accession"] == "a3"
|
| 79 |
+
assert latest["10-K"]["primaryDocument"] == "a3.htm"
|
| 80 |
+
assert latest["10-Q"]["accession"] == "a5"
|
| 81 |
+
assert latest["8-K"]["accession"] == "a1", "latestByForm must pick the newest 8-K (a1), not some other one"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_build_filings_list_payload_raises_for_unknown_ticker(monkeypatch) -> None:
|
| 85 |
+
monkeypatch.setattr(payloads, "get_ticker_to_cik_dict_cached", lambda: {})
|
| 86 |
+
|
| 87 |
+
with pytest.raises(HTTPException) as exc:
|
| 88 |
+
payloads.build_filings_list_payload("BOGUS")
|
| 89 |
+
assert exc.value.status_code == 404
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_build_filings_list_payload_handles_empty_upstream(monkeypatch, cik_mapping) -> None:
|
| 93 |
+
monkeypatch.setattr(payloads, "get_company_filings", lambda cik, include_8k=False: pd.DataFrame())
|
| 94 |
+
|
| 95 |
+
result = payloads.build_filings_list_payload("AAPL")
|
| 96 |
+
assert result["filings"] == []
|
| 97 |
+
assert result["forms"] == []
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_build_filing_document_payload_returns_markdown_and_camelcase_toc(monkeypatch, cik_mapping) -> None:
|
| 101 |
+
monkeypatch.setattr(payloads, "download_filing", lambda cik, acc, doc: "<html>ignored</html>")
|
| 102 |
+
|
| 103 |
+
fake_md = "## PART I\n\nbody\n\n## PART II\n"
|
| 104 |
+
monkeypatch.setattr(payloads, "parse_sec_filing", lambda html, form, *, include_images=False: fake_md)
|
| 105 |
+
|
| 106 |
+
result = payloads.build_filing_document_payload(
|
| 107 |
+
"AAPL",
|
| 108 |
+
accession="0000320193-25-000001",
|
| 109 |
+
primary_document="aapl-20241228.htm",
|
| 110 |
+
form="10-K",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
assert result["ticker"] == "AAPL"
|
| 114 |
+
assert result["accession"] == "000032019325000001"
|
| 115 |
+
assert result["markdown"] == fake_md
|
| 116 |
+
assert result["charCount"] == len(fake_md)
|
| 117 |
+
# Every TOC entry must expose camelCase keys consistent with the rest of the API.
|
| 118 |
+
assert all({"level", "text", "lineIndex", "slug", "charCount"}.issubset(e) for e in result["toc"])
|
| 119 |
+
assert [e["text"] for e in result["toc"]] == ["PART I", "PART II"]
|
| 120 |
+
# documentUrl opens the iXBRL viewer, not the raw archive path.
|
| 121 |
+
assert result["documentUrl"].startswith("https://www.sec.gov/ix?doc=/Archives/edgar/data/")
|
| 122 |
+
assert result["documentUrl"].endswith("aapl-20241228.htm")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_build_filing_document_payload_requires_primary_document(monkeypatch, cik_mapping) -> None:
|
| 126 |
+
with pytest.raises(HTTPException) as exc:
|
| 127 |
+
payloads.build_filing_document_payload("AAPL", accession="0000320193-25-000001", primary_document="")
|
| 128 |
+
assert exc.value.status_code == 400
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_build_filing_document_payload_propagates_unsupported_form_as_422(monkeypatch, cik_mapping) -> None:
|
| 132 |
+
monkeypatch.setattr(payloads, "download_filing", lambda cik, acc, doc: "<html></html>")
|
| 133 |
+
|
| 134 |
+
def bad_parse(*_args, **_kwargs):
|
| 135 |
+
raise ValueError("Filing form 'DEF 14A' not supported.")
|
| 136 |
+
|
| 137 |
+
monkeypatch.setattr(payloads, "parse_sec_filing", bad_parse)
|
| 138 |
+
|
| 139 |
+
with pytest.raises(HTTPException) as exc:
|
| 140 |
+
payloads.build_filing_document_payload(
|
| 141 |
+
"AAPL",
|
| 142 |
+
accession="0000320193-25-000001",
|
| 143 |
+
primary_document="def14a.htm",
|
| 144 |
+
form="DEF 14A",
|
| 145 |
+
)
|
| 146 |
+
assert exc.value.status_code == 422
|