Backend for Valuate — AI-augmented DCF agent over SEC 10-K filings. Live at valuate.kristenmartino.ai · Frontend repo: valuate-web · Case study: kristenmartino.ai/work/valuate
The case study has the full design narrative; this README is a working reference for the code.
POST /extract { ticker } ──▶ Company (cached server-side)
│
├──▶ GET /company/{ticker} read cached
├──▶ PUT /company/{ticker}/override HITL correction (Bearer-token auth)
├──▶ GET /value/{ticker}/defaults starting Assumptions
├──▶ POST /value/{ticker} DCF + MC + sensitivity
└──▶ GET /comps/{ticker} peer multiples
GET /healthz liveness probe
GET /version commit SHA + prompt hash + start time
A single FastAPI app serving a LangGraph state machine that extracts financial line items from a company's most recent 10-K, lets a reviewer override flagged extractions, then computes a 5-year DCF projection plus 10K-iteration Monte Carlo and a 7×7 sensitivity grid.
The graph (graph.py) runs ingest → track_a → track_b → validate → END:
-
Ingest —
EdgarClientfetches the latest 10-K's metadata, the XBRL company-facts JSON, and the filing's primary HTML URL. The SIC code from the SEC submissions response routes the rest of the pipeline through the right industry path (industry.py→Industry.STANDARDfor industrials/tech,Industry.BANKfor depositories,Industry.INSURERfor insurance carriers,Industry.REITfor real estate trusts,Industry.ENERGYfor oil & gas E&P / refining). Rate-limited to SEC's 10 req/s limit. -
Track A — XBRL (
extract_track_a.py). Walks the industry-specific concept map (STANDARD_CANONICAL_CONCEPTSfor industrials,BANK_CANONICAL_CONCEPTSfor banks — banks tag net interest income, loans, deposits, etc. that don't exist in the standard schema;REIT_CANONICAL_CONCEPTSadds the real-estate-at-cost / accumulated-depreciation contra-asset / real-estate-net trio that REITs report on the balance sheet). Returns a flat dict of LineItems for the 5 most-recent fiscal years (XBRL company-facts already carries every year the filer has tagged, so multi-period costs zero extra HTTP). Missing concepts come back asNone; never raises. -
Track B — Claude (
extract_track_b.py). For the latest period only, asks Claude (claude-sonnet-4-6, prompt-cached system prompt) to fill any fields Track A left blank, plus extract revenue by segment if the filer reports it. Every value carries a verbatim source quote and a confidence score. -
Derivation backstop (in
graph.py). For fields neither track filled, applies accounting-identity fallbacks:operating_income ≈ income_before_tax + interest_expense(handles JNJ, NKE)total_liabilities = total_assets − shareholders_equity(handles NKE, KO)real_estate_net = real_estate_at_cost − accumulated_depreciation(REIT-only, for filers that tag the components but not the net)
All write
source=DERIVEDwith a synthetic source quote. -
Composition — builds a
Companywith up to 5FinancialPeriods, dispatching to the right schema variants per the industry:IncomeStatement/BalanceSheet/CashFlowStatementfor standard and energy filers (E&P companies report on the same shape as industrials — what differs is the valuation, not the line items),BankIncomeStatement/BankBalanceSheet/BankCashFlowStatementfor banks, the equivalentInsurance*triples for insurers, andREIT*triples for REITs. Pydantic discriminated unions on each statement (kindliteral) keep the JSON shape unambiguous on the wire. The latest period must be complete orCompositionErrorraises (HTTP 422); older periods with thin coverage are silently dropped. -
Validate — flags low-confidence items (<0.80) and balance-sheet identity violations (>50bps tolerance) as
ExtractionFlags on the response.
compute_projection in dcf.py dispatches by industry:
- Standard (industrials / tech): 5-year FCFF DCF with Gordon-growth terminal, plus 10K-iteration Monte Carlo and a 7×7 sensitivity grid over (revenue growth × operating margin).
- Bank: Gordon dividend discount model —
P = D₀(1 + g) / (r − g), wherewaccis reinterpreted as cost of equity andterminal_growthas long-term dividend growth. The defaultgis observed dividend CAGR capped 200bps under defaultrso the Gordon constraint holds out of the box. - Insurer: justified price-to-book —
P/B = (ROE − g) / (r − g), thenfair_value/share = book_value/share × P/B. Reserves and the general-account investment portfolio dominate the balance sheet, so book value is the economic anchor. - REIT: FFO-multiple Gordon growth —
fair_value/share = FFO/share × (1 + g) / (r − g), whereFFO = net income + D&A. GAAP depreciation overstates economic depreciation for well-maintained real estate, so FFO is the conventional pre-distribution earnings measure REIT analysts anchor on. - Energy E&P: 10-year reserve-life-capped FCFF, no terminal value. Reserves deplete; Gordon-growth-to-infinity is conceptually wrong for an asset that will run out.
revenue_growthis reinterpreted as production growth/decline. E&P is the only flavor that doesn't require a separate schema variant — the line items are standard us-gaap, what differs is the valuation math.
Monte Carlo runs for all five flavors (degenerate axes are simply unsampled); sensitivity is hidden client-side for bank/insurer/REIT paths because the grid axes (revenue growth × operating margin) don't enter their formulas. For energy E&P the heatmap IS shown — the FCFF math still uses both axes, just without a terminal value.
PUT /company/{ticker}/override accepts { field_path, value, source_quote? } and replaces the LineItem at the given path with source=USER_OVERRIDE. Validation re-runs after every override so flags reflect the new state.
Two repos back this:
- InMemoryRepo (default in local dev) — process-local dict, wiped on restart.
- PostgresRepo — turns on automatically when
DATABASE_URLis set. One JSONB-backedcompaniestable; an override is recorded inline on the Company JSON — the corrected LineItem is replaced by one taggedsource=user_override(confidence 1.0) carrying the reviewer'ssource_quote. It's a current-state snapshot, not a change-history log.
- Python 3.11, FastAPI + LangGraph
- SEC EDGAR for both XBRL company-facts and 10-K HTML
- Anthropic SDK (
claude-sonnet-4-6) with prompt caching on the static system prompt - BeautifulSoup + lxml for slicing the 10-K's Item 8 financial-statements section before sending to Claude
- yfinance for peer market multiples (no API key, runs in a thread pool)
- asyncpg for Postgres persistence (optional)
git clone https://github.com/kristenmartino/valuate-api
cd valuate-api
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in SEC_USER_AGENT and ANTHROPIC_API_KEY
uvicorn app.main:app --reloadThe server listens on http://127.0.0.1:8000 by default. Without DATABASE_URL it uses InMemoryRepo, which means overrides are lost when you restart uvicorn — that's fine for local exploration.
pytest tests/56 tests across five files:
test_extraction.py(26) — bugs that bit during development plus per-industry valuation mathtest_dcf.py(7) — standard FCFF DCF, Monte Carlo, and sensitivity-grid mathtest_overrides.py(10) — HITL override across every industry variant, with multi-period history preservedtest_auth_and_rate_limit.py(12) — bearer-token auth on/overrideand the IP rate limiter for/extract(incl. X-Forwarded-For spoof resistance)test_integration.py(1, network-gated) — end-to-end against a real AAPL 10-K
The default pytest tests/ skips the network-gated test. Run it explicitly with pytest tests/ -m network (requires SEC_USER_AGENT). It's intended as a periodic deploy health check, but is run manually today — there's no scheduled CI job wired up yet.
The extraction tests cover:
latest_value_per_periodkeying byenddate rather than the filing'sfy(a 10-K filed for FY2025 reports comparative income statements for FY2024 and FY2023, all taggedfy=2025; grouping byfycollides three years of data into one slot)- restatement dedup picks the higher-accession version
- alternate-tag fall-through with confidence 0.95 vs primary 1.0
- missing concepts return
None, never raise - the DERIVED fallbacks (op income from IBT + interest, total liabilities from the balance-sheet identity, REIT real-estate-net from at-cost minus accumulated depreciation)
_recent_period_endsordering and anchor clipping_compose_companysilently drops older periods with required-field gaps but raises on the latestdefault_assumptionsaverages ratios across the multi-year window and estimatesrevenue_growthfrom observed CAGRclassify_sicroutes the five industry buckets (and rejects malformed input)- per-industry valuation formulas match hand-computed expected values: Gordon DDM for banks (with
r > gconstraint enforcement), justified P/B for insurers, FFO-multiple Gordon for REITs, and 10-year FCFF with zero terminal value for energy E&P
The service runs on Railway. The repo is set up for one-click deploy:
Procfilerunsuvicorn app.main:app --host 0.0.0.0 --port $PORTrailway.tomlsets the healthcheck path to/healthzand the restart policyruntime.txtpins Python 3.11.10
Required env vars (set in the Railway project UI):
| Variable | Required | Notes |
|---|---|---|
SEC_USER_AGENT |
yes | SEC blocks requests without one. Format: "Your Name your.email@domain.com" |
ANTHROPIC_API_KEY |
yes | Track B and segment extraction need it; sk-ant-... from console.anthropic.com/settings/keys |
DATABASE_URL |
optional | Auto-injected by Railway's Postgres plugin. Without it, persistence falls back to in-memory. |
VALUATE_OVERRIDE_TOKEN |
optional | When set, PUT /company/{ticker}/override requires Authorization: Bearer <token>. The Vercel frontend injects the same token via its proxy.ts from a same-named env var. When unset, /override runs unauthenticated (fine for local dev). |
VALUATE_EXTRACT_RATE_LIMIT |
optional | IP rate limit on POST /extract, format <count>/<window-seconds>. Default 10/3600. Only /extract is limited (it's the Anthropic-burning endpoint); read endpoints aren't. |
VALUATE_DISABLE_STRUCTURED_LOGGING |
optional | Set to 1 to skip the JSON-per-request middleware (app/logging_middleware.py). Useful for local dev when reading plain Python tracebacks in stderr is preferable. |
SENTRY_DSN |
optional | When set, FastAPI exceptions get reported to Sentry. Requires pip install sentry-sdk[fastapi] (commented out in requirements.txt; uncomment to bake in). Off by default; lazy-imports the SDK so the dep is opt-in. |
/versionreturns the running commit SHA (from Railway's auto-injectedRAILWAY_GIT_COMMIT_SHA), deployment ID, service start timestamp, environment name, and the system-prompt sha256 — a "did the deployed prompt change?" diagnostic distinct from the commit SHA, since the prompt can change without a code commit (e.g. after a prompt-eval iteration).- Structured request logging (
app/logging_middleware.py) emits one JSON line per request to stdout withrequest_id,method,path,status,duration_ms,client_ip. TheX-Request-IDheader is set on every response so error reports are grep-able. Skipped viaVALUATE_DISABLE_STRUCTURED_LOGGING=1. - Optional Sentry (
app/sentry_setup.py) activates whenSENTRY_DSNis set in the env. Lazy-importssentry-sdkso it's truly opt-in.
eval/ holds hand-pinned, SEC-sourced ground-truth values spanning all five industry categories — standard/tech (AAPL, MSFT, GOOGL, AMZN, NKE, KO), banks (JPM, BAC), insurer (PRU), REIT (PLD), and energy E&P (EOG). The runner scores both extraction tracks per field within ±0.5% tolerance:
- Track A (XBRL) —
extract_track_aover the filer's company-facts, for fields in the industry's canonical concept map. - Track B (Claude) —
extract_track_breading the filing HTML, for fields XBRL doesn't cover (e.g.income_before_tax/income_tax_expenseon standard filers).
Each field resolves to PASS / FAIL / REFRESH (filer rolled to a fiscal year newer than the pinned ground truth — a refresh is needed, not an extraction bug) / SKIP (the field's track wasn't run).
SEC_USER_AGENT="..." ANTHROPIC_API_KEY="..." python -m eval.run_eval
# --readme emit the baseline table below --json machine-readable
# --tickers AAPL,JPM run a subset --track-a-only XBRL only, no API keyExit codes: 0 all pass, 1 a real extraction mismatch, 2 no fails but a ground-truth refresh is needed (so a cron can tell "model regressed" from "human action needed"). Intended use: run before merging changes to extraction_prompt.py, and on a cadence to catch model-version regressions.
Last refreshed: 2026-05-30
| Scope | Tickers | Fields | Accuracy |
|---|---|---|---|
| XBRL Track A | 11 | 39 | 100.0% |
| Claude Track B | 3 | 4 | 75.0% |
| Overall | 11 | 43 | 97.7% |
Measured across 11 filers in five industries. Track A (XBRL) reproduces every filer's officially-tagged value for all 39 canonical-concept fields within ±0.5%. Track B (Claude) covers the four income-tax fields the XBRL concept map intentionally leaves to the LLM (income_before_tax / income_tax_expense): 3 of 4 within ±0.5%. The single miss is an omission, not a wrong number — Claude returned no value for MSFT's income_before_tax (not_extracted), a field that isn't required for the DCF. (Refresh these figures with python -m eval.run_eval --readme.)
This is an eval baseline over a curated public-filer set, not a guarantee across all SEC filers.
The threat model is "random scraping / accidental corruption," not "sophisticated adversary." /override is the only destructive endpoint and is gated by a single shared bearer token; /extract is rate-limited by IP because each call costs real Anthropic credits. A production deployment would put both behind real per-user auth; the case study acknowledges this.
Local dev with both env vars unset behaves identically to the pre-auth era — useful for poking at the override flow without setting up tokens.
18 hand-picked S&P 500 tickers — 14 industrial / tech filers (AAPL, MSFT, GOOGL, AMZN, META, NVDA, COST, WMT, HD, NKE, JNJ, KO, F, CAT), one bank (JPM), one life insurer (PRU), one industrial REIT (PLD), and one pure-play E&P (EOG). Three of the original ten industrials needed Track B or DERIVED fallback to compose successfully; the four variant-industry filers (JPM, PRU, PLD, EOG) and the four later-added megacaps (AMZN, META, WMT, F) all extracted cleanly. WMT specifically needed Track B to fill depreciation_amortization from the filing text — exactly the case Track B was designed for.
| Industry | Status | Valuation method | Sample ticker |
|---|---|---|---|
| Industrial / tech | shipped | 5-year FCFF DCF + Monte Carlo + sensitivity | AAPL, MSFT, ... |
| Banks | shipped | Gordon DDM | JPM |
| Insurers | shipped | Justified P/B | PRU |
| REITs | shipped | FFO-multiple Gordon growth | PLD |
| Energy E&P | shipped | 10-year reserve-life-capped FCFF (no terminal) | EOG |
E&P is the only industry that doesn't carry a separate schema variant — the line items E&P companies report (revenue, op income, capex, D&A) are standard us-gaap; the conceptual difference is the valuation, not the data shape. The architecture supports both: schema variants when the line-item set fundamentally differs (banks, insurers, REITs), and dispatch-only when only the valuation differs (E&P). Anything classified outside these five falls back to Industry.STANDARD and runs the FCFF path with Gordon terminal — which produces nonsense for filers it shouldn't apply to. The home page surfaces the 18 curated tickers as the primary entry point and a free-text search box as an escape hatch; the search copy makes the fallback caveat explicit.
Other items deliberately parked in the later label: segment-aware DCF (currently consolidated only), multi-period filing-accession attribution, saved scenarios.
MIT