sk851 Claude Opus 4.7 (1M context) commited on
Commit
7b8e8bb
·
1 Parent(s): 80a489a

feat(agent,interface): expose SEC filings through agent capabilities and stock page

Browse files

Wires 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 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. The page
297
- itself uses the shared TerraFin chart session and progressive `3Y -> full`
298
- history loading described in [chart-architecture.md](./chart-architecture.md).
 
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": output.df.to_dict(orient="records"),
521
- "count": len(output.df),
 
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(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "status": dcf.get("status"),
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
- "trailing_pe": trailing_pe,
729
- "forward_pe": forward_pe,
730
- "price_to_book": (
731
  round(current_price / bvps, 2)
732
  if current_price and bvps and bvps > 0 else None
733
  ),
734
  },
735
- "graham_number": graham_number,
736
- "margin_of_safety_pct": margin_of_safety,
737
- "current_price": current_price,
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. Do not use unless the request is view-dependent."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = `![Company logo](<inline-image:image/png>)`;
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 `![alt](src)` 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&rsquo;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
- export type { CompanyInfo, EarningsRecord, FinancialRow, FinancialStatement, ChartPoint };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- assert snapshot["market_breadth"][0]["label"] == "Advancers"
154
- assert snapshot["watchlist"][0]["symbol"] == "AAPL"
 
 
 
 
 
 
 
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