Indian-market investing research · a decision brief, never a verdict
Build Folio, one pipeline stage at a time.
A build‑along reference for a personal-use research assistant that reads concalls, annual reports, DRHPs and news, and explains the whats / hows / whys / whens in plain language — backed by numbers that were computed in code, not recalled by the model, and every one of them citing its source. It never says buy or sell. It hands you the bull case, the bear case, what changed, and the flags, so you make the call. Each milestone is a stage in the pipeline below, and each one lands one of the agentic topics you've been studying.
What you're building
Folio is a pipeline, and you'll build it one stage at a time, from scratch, in this guide. A query comes in; it leaves as a decision brief with a citation on every claim. Nothing here assumes code you wrote elsewhere — each stage is defined and built on its own:
- The router — turns a fuzzy query into a typed plan the rest of the graph can dispatch on.
- Hybrid retrieval — BM25 + FAISS + RRF over the document corpus.
- Rerank & refine — a cross-encoder for precision, then compression to fit precise, still-attributable context into the window.
- The numbers layer — the deterministic heart: extract line items, compute every ratio in code.
- Synthesis — citation-first assembly of the brief, provenance on every claim.
- The audit — RAGAS faithfulness plus a numbers-audit that fails the run on any uncited figure.
The genuinely hard parts — the deterministic numbers layer, the numbers-audit, the multi-agent analysts — get the most room; the well-trodden parts (a FAISS index, a cross-encoder rerank) are built plainly and quickly so you can move on. Every milestone header lists the lecture topics it turns into code, so the build doubles as the applied half of the course.
Design principles & the pluggable seam
Two rules shape the whole system. Encode them now, in code, so they can't be quietly violated later:
- Numbers are computed in code, never recalled or arithmetic'd by the LLM. The model may read a line item and narrate a figure — it may never calculate one. Extraction and arithmetic are Python.
- Every number cites its source, and an audit enforces it. In finance, faithfulness is the product. This is the line between Folio and "take a ticker, print a blurb."
Project layout (modular uv / src)
uv init folio && cd folio
mkdir -p src/folio/{providers,corpus,route,retrieve,compute,synthesize,eval,receipts}
mkdir -p tests data/{corpus,index,cache} scripts
uv add pydantic pydantic-settings numpy python-dateutil
uv add langchain langchain-community langchain-anthropic langchain-openai
uv add anthropic openai # only the provider(s) you'll wire
uv add --dev pytestConvention, standing: LangChain-native only (no from-scratch reimplementations of things LangChain
gives you), and every trace/receipt is a typed dataclass with a .render() method
so observability is a first-class object, not print statements.
src/folio/config.py
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
llm: Literal["anthropic", "openai", "fake"] = "fake" # fake => ZERO keys
embedder: Literal["local", "openai", "fake"] = "local"
anthropic_chat_model: str = "claude-sonnet-4-5" # the default brain
openai_chat_model: str = "gpt-4.1-mini" # the alternate
local_embed_model: str = "sentence-transformers/all-MiniLM-L6-v2"
anthropic_api_key: str = ""
openai_api_key: str = ""
# retrieval knobs
retrieval_top_k: int = 20 # hybrid returns this many
rerank_top_k: int = 6 # this many survive the reranker
# the hard guardrail: staleness horizon (days) before data must be re-fetched
freshness_days: int = 3
index_dir: str = "./data/index"
corpus_dir: str = "./data/corpus"
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
@lru_cache(maxsize=1)
def get_settings() -> "Settings":
return Settings()src/folio/providers/base.py — the interfaces
from typing import Protocol, runtime_checkable, Any
import numpy as np
@runtime_checkable
class Chatter(Protocol):
def answer(self, prompt: str, *, system: str = "", tools: list | None = None) -> Any: ...
@runtime_checkable
class Embedder(Protocol):
dim: int
def embed(self, texts: list[str]) -> np.ndarray: ... # (n, dim) float32, unit-normWire the concrete AnthropicChatter / OpenAIChatter /
FakeChatter with the same discipline: lazy provider imports (so a provider you don't
use never has to be installed), fail loudly if a selected provider's key is empty, and a
FakeChatter that echoes the prompt's evidence so you can debug retrieval without a
fluent model papering over it.
The receipt base — observability as a typed object
# src/folio/receipts/base.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class Provenance:
"""Where a fact came from. Attached to EVERY number and EVERY claim."""
doc_id: str # "TCS_concall_Q1FY26.pdf"
locator: str # "p.7 · CFO remarks" or a table cell ref
as_of: datetime # when this datum was true / fetched
kind: Literal["filing", "price", "screener", "news", "computed"] = "filing" # type: ignore
def is_stale(self, horizon_days: int) -> bool:
return (datetime.now(timezone.utc) - self.as_of).days > horizon_days
def render(self) -> str:
return f"[{self.doc_id} · {self.locator} · as of {self.as_of:%Y-%m-%d}]"
@dataclass
class Receipt:
"""Base for every stage's trace. Subclasses add stage-specific fields."""
stage: str
started: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
notes: list[str] = field(default_factory=list)
def render(self) -> str: # each stage overrides for a richer view
return f"── {self.stage} ──\n" + "\n".join(f" · {n}" for n in self.notes)The financial corpus & the freshness rule
Getting clean, current Indian-market data is the least glamorous, most load-bearing part of the whole thing. There is no clean official free price API, so you compose unofficial, rate-limited, fragile sources — and the guardrail against that fragility is that every datum is date-stamped, so stale data can never masquerade as current.
The India sources (personal use, free tier)
Prices / quotes nsepython · jugaad-data · yfinance (".NS" suffix) unofficial, rate-limited
Fundamentals Screener.in (exportable) · NSE/BSE announcements retail gold standard
Concalls / AR company IR pages, exchange sites PDFs — your wheelhouse
IPOs SEBI (DRHP/RHP), exchanges, Chittorgarh Chittorgarh = retail-friendly
GMP grey-market aggregators UNOFFICIAL — caveat always
Mutual funds AMFI (official NAV history) · factsheets deferred to v4A Document that carries its own freshness
# src/folio/corpus/types.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class SourceDoc:
doc_id: str # stable id, used as the citation key
ticker: str # "TCS", "INFY" (NSE symbol)
doc_type: str # "concall" | "annual_report" | "drhp" | "news" | "fundamentals"
text: str
as_of: datetime # publication / fetch date — the freshness anchor
fiscal_period: str = "" # "Q1FY26", "FY25" — parsed, not guessed
url: str = ""
meta: dict = field(default_factory=dict)Loaders (one per source) return SourceDocs. PDFs go through an extraction pass
— pdfplumber for tables, unstructured or
PyMuPDF for prose — and concalls, annual reports and DRHPs are all just PDFs
of different shapes. The jobs unique to finance are stamping as_of and parsing
fiscal_period from the Indian April–March calendar.
The freshness gate
# src/folio/corpus/freshness.py
from datetime import datetime, timezone
from folio.corpus.types import SourceDoc
class StaleDataError(RuntimeError):
"""Raised when a datum that must be current (a price) is older than the horizon."""
def assert_fresh(doc: SourceDoc, horizon_days: int) -> None:
age = (datetime.now(timezone.utc) - doc.as_of).days
if doc.doc_type in {"price", "fundamentals"} and age > horizon_days:
raise StaleDataError(
f"{doc.ticker} {doc.doc_type} is {age}d old (horizon {horizon_days}d). "
f"Re-fetch before quoting it as current.")Filings and DRHPs are meant to be historical, so they never trip the gate — only things that claim to be "current" (prices, live fundamentals) do. This is the freshness rule made executable: the LLM never states a price or figure it didn't get from a tool or retrieval, and stale current-data fails loudly instead of lying quietly.
The router: intent → a typed plan
Build the front door. Its one job: read the user's query and emit a typed plan — not prose, a Pydantic object the rest of the graph can dispatch on. This is "Multi-Source Routing" and "Structured Outputs" married: the LLM classifies intent, and the type system guarantees the downstream graph gets something it can actually execute.
The plan is a type, so a bad plan can't exist
# src/folio/route/plan.py
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
Intent = Literal["single_stock", "ipo", "portfolio", "mutual_fund", "news", "glossary"]
class ResearchPlan(BaseModel):
intent: Intent
tickers: list[str] = Field(default_factory=list) # ["TCS"] — NSE symbols
ipo_name: str = "" # for intent="ipo"
needs: list[Literal["concall", "annual_report", "drhp",
"fundamentals", "price", "news"]] = Field(default_factory=list)
horizon: Literal["latest_quarter", "3yr", "since_purchase"] = "latest_quarter"
reason: str = "" # why this route — for the receiptThe router node
# src/folio/route/switchboard.py
from folio.route.plan import ResearchPlan
from folio.providers.base import Chatter
_SYS = """You are a router for an Indian-market research assistant. Classify the query
into exactly one intent and list the documents needed. You NEVER answer the question,
NEVER give buy/sell opinions — you only produce a plan. Fiscal years are April–March."""
def route(query: str, chatter: Chatter) -> ResearchPlan:
# LangChain structured output: the model is forced into the ResearchPlan schema
plan = chatter.answer(query, system=_SYS, tools=None) # returns a validated ResearchPlan
return plan # invalid JSON / bad enum → pydantic raises here, at the front doorUse LangChain's structured-output binding (with_structured_output(ResearchPlan))
so the model is constrained to the schema. A hallucinated intent or a malformed plan fails here,
at the front door — the cheapest possible place for it to fail.
Hybrid retrieval over the filings
Retrieval has two failure modes, and finance triggers both. Semantic (dense/FAISS) search paraphrases
away exact terms; lexical (BM25) search misses concepts phrased differently. Financial documents are dense
with exact terms — "deferred tax liability", "promoter pledge",
"related party transactions", section numbers, line items — so you need both:
BM25 nails the exact token, FAISS catches the conceptual rest, and Reciprocal Rank Fusion (RRF)
combines their rankings into one list.
# src/folio/retrieve/hybrid.py
from rank_bm25 import BM25Okapi # or LangChain's BM25Retriever
# FAISS store: faiss.IndexFlatIP(dim) over unit-norm embeddings (cosine == inner product);
# Embedder from the M0 provider seam. Both built plainly in src/folio/retrieve/.
def rrf(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
"""Reciprocal Rank Fusion — fuse on RANK POSITION, not raw score.
A FAISS cosine of 0.71 and a BM25 score of 14.2 aren't comparable,
but 'ranked 2nd in each list' is."""
scores: dict[str, float] = {}
for ranking in rankings:
for rank, chunk_id in enumerate(ranking):
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1)
return scores
def hybrid_search(query, bm25, faiss_store, embedder, top_k):
lexical = bm25.rank(query) # list[chunk_id], best first
dense = faiss_store.search(embedder.embed([query])[0], top_k) # list[(chunk, score)]
fused = rrf([lexical, [c.chunk_id for c, _ in dense]])
ranked = sorted(fused, key=fused.get, reverse=True)[:top_k]
return rankedRerank & refine the context
A 200-page annual report blows any context window, and stuffing 20 fused chunks in dilutes the model's
attention with near-misses. This stage does two things: a cross-encoder rerank (precision over the
recall-oriented hybrid list) and a compression pass that trims each surviving chunk to only the
sentences that matter — without ever dropping the Provenance. This is
"Context Optimization" and "Summarization" doing the same job: fit precise, still-attributable context into
the window.
The funnel receipt — observability you can read
# src/folio/receipts/funnel.py
from dataclasses import dataclass, field
from folio.receipts.base import Receipt
@dataclass
class FunnelReceipt(Receipt):
hybrid_in: int = 0 # chunks from M3
reranked: int = 0 # after cross-encoder
kept: int = 0 # after top-k cut
chars_before: int = 0
chars_after: int = 0 # after compression
per_chunk: list[dict] = field(default_factory=list) # {id, hybrid_rank, ce_score, kept}
def render(self) -> str:
pct = 100 * self.chars_after // max(self.chars_before, 1)
head = (f"── refine ── {self.hybrid_in} → rerank → keep {self.kept} "
f"context {self.chars_before}→{self.chars_after} chars ({pct}%)")
rows = "\n".join(
f" {'✓' if c['kept'] else '·'} {c['id'][:12]} "
f"hy#{c['hybrid_rank']:<2} ce={c['ce_score']:+.2f}" for c in self.per_chunk)
return head + "\n" + rowsBeing able to see which chunk was ranked where, scored what by the cross-encoder, and whether it
survived — that's the difference between debugging retrieval and guessing. Print
FunnelReceipt.render() on every run during development.
The numbers layer — the heart of the app
This is the milestone that makes Folio trustworthy, and the one with no shortcuts. The split is absolute:
- The LLM extracts line items from a filing into a typed structure (that's reading, which it's good at).
- Python computes every ratio from those line items (that's arithmetic, which the LLM must never do).
- The LLM later narrates the computed figures — but the figures themselves are code output,
each carrying
kind="computed"provenance that points back to the line items it was built from.
Extraction: LLM reads line items into a type
# src/folio/compute/lineitems.py
from pydantic import BaseModel
from folio.receipts.base import Provenance
class LineItem(BaseModel):
name: str # "revenue", "net_profit", "total_debt", "shareholder_equity"
value_inr: float # RAW rupees — never crore/lakh internally
period: str # "Q1FY26", "FY25"
prov: Provenance # exactly where in the filing this number was read from
# The LLM's ONLY numeric job: extract, with the page/table locator, into LineItem[].
# It is explicitly instructed NOT to compute, sum, or infer any value.Computation: deterministic, in code, provenance-tracked
# src/folio/compute/ratios.py
from dataclasses import dataclass
from datetime import datetime, timezone
from folio.compute.lineitems import LineItem
from folio.receipts.base import Provenance
@dataclass
class ComputedFigure:
name: str
value: float
unit: str # "%", "x", "₹ cr"
inputs: list[Provenance] # the line items this was computed FROM
formula: str # "net_profit / revenue * 100" — human-auditable
def prov(self) -> Provenance:
return Provenance(doc_id="computed", locator=self.formula,
as_of=datetime.now(timezone.utc), kind="computed")
def net_margin(rev: LineItem, pat: LineItem) -> ComputedFigure:
return ComputedFigure("net_margin", round(pat.value_inr / rev.value_inr * 100, 2),
"%", [rev.prov, pat.prov], "net_profit / revenue * 100")
def debt_to_equity(debt: LineItem, equity: LineItem) -> ComputedFigure:
return ComputedFigure("debt_to_equity", round(debt.value_inr / equity.value_inr, 2),
"x", [debt.prov, equity.prov], "total_debt / shareholder_equity")
# P/E, ROCE, 3-yr revenue/PAT CAGR, margin trend — all the same shape:
# pure functions of LineItems, each returning a ComputedFigure with formula + input provenance.Synthesis: the decision brief
Now assemble the brief: citation-first synthesis where every claim carries provenance. The load-bearing
part is the output schema, and its most important property is what it cannot represent.
There is no recommendation: buy|hold|sell field — because the design principle
("research assistant, not decision oracle") is enforced by the type, not by a polite instruction the model
might drift from.
The brief — a fixed schema of decision factors, each evidence-backed
# src/folio/synthesize/brief.py
from pydantic import BaseModel
from folio.receipts.base import Provenance
class Claim(BaseModel):
text: str # a single, plain-language statement
prov: list[Provenance] # ≥1 source. A Claim with no provenance is INVALID by construction.
class NumberedClaim(Claim):
figure: float
unit: str # this claim asserts a COMPUTED number → must trace to a ComputedFigure
class DecisionBrief(BaseModel):
ticker: str
as_of: str
business: Claim # how they actually make money, plainly
financials: list[NumberedClaim] # 3-yr revenue / PAT / margin — computed, not recalled
valuation: list[NumberedClaim] # P/E, ROCE, debt vs own history & sector
bull_case: list[Claim]
bear_case: list[Claim]
what_changed: list[Claim] # delta vs last quarter
flags: list[Claim] # margin compression, promoter pledge, mgmt change, RPTs, thesis-breaking news
# NOTE: there is deliberately NO recommendation field. You make the call.The ReAct research agent
So far the pipeline is a fixed graph. But real research is iterative: read the concall → notice a margin dip → go compute the margin trend → that raises a debt question → fetch the debt line items. That's the ReAct loop — reason, act (call a tool), observe, repeat — and it's the right shape for the synthesis stage of a single-stock or IPO brief.
# src/folio/synthesize/agent.py (LangGraph ReAct — sketch)
TOOLS = [
retrieve_chunks, # M3+M4: hybrid + crucible over the corpus
extract_line_items, # M5: LLM reads a filing section into LineItem[]
compute_ratio, # M5: deterministic ratio functions (the model CANNOT do the math)
get_price, # M1/M5: read-only, freshness-gated
search_news, # cross-cutting: recency-filtered, source-quality-gated
]
# ReAct: the model proposes a tool call → runtime executes → observation returns →
# repeat until it has enough EVIDENCE to fill the DecisionBrief schema. It stops when
# every required field has ≥1 sourced Claim, not when it "feels done".Eval harness: RAGAS faithfulness + numbers-audit
Two evals, doing two different jobs. RAGAS scores general faithfulness (does the prose stay grounded in the
retrieved context?). The numbers-audit is the domain-specific gate that makes this a finance tool:
extract every quantitative claim from the brief → verify each appears in a source doc or was produced by a
ComputedFigure → fail the run on any orphan.
# src/folio/eval/numbers_audit.py
from dataclasses import dataclass
from folio.synthesize.brief import DecisionBrief, NumberedClaim
@dataclass
class AuditResult:
total_numbers: int
orphans: list[str] # numbers with no source / no computed lineage
stale: list[str] # numbers whose provenance is past the freshness horizon
@property
def passed(self) -> bool:
return not self.orphans and not self.stale
def audit(brief: DecisionBrief, horizon_days: int) -> AuditResult:
orphans, stale, n = [], [], 0
for claim in _all_numbered_claims(brief): # walk financials + valuation
n += 1
if not claim.prov: # should be impossible — schema forbids — but verify
orphans.append(claim.text); continue
if all(p.is_stale(horizon_days) and p.kind in {"price", "fundamentals"} for p in claim.prov):
stale.append(claim.text)
return AuditResult(n, orphans, stale)v2 — the IPO explainer from the DRHP
The DRHP (Draft Red Herring Prospectus) is the single most information-dense document retail investors
ignore. The beauty of the pipeline you've built: an IPO explainer is mostly reuse. Same route
(intent="ipo"), same hybrid retrieval, same rerank & refine, same numbers layer, same
citation-first synthesis, same audit. What changes is the loader (a DRHP is a specific PDF shape) and the
output schema.
The IPO brief schema
class IPOBrief(BaseModel):
company: str
as_of: str
business: Claim # how the business makes money
financials: list[NumberedClaim] # 3-yr revenue / PAT trend — computed
use_of_proceeds: list[Claim] # what they'll do with the money
key_risks: list[Claim] # distilled from the legally-required risk-factors section
shareholding: list[Claim] # promoter / shareholding pattern
valuation_vs_peers: list[NumberedClaim] # P/E vs listed comparables
red_flags: list[Claim] # RPTs, litigation, declining margins
gmp: Claim | None = None # ONLY with the hard caveat; never a thesis basisv3 — portfolio decision brief & the thesis journal
The reshape is the whole point: this was "hold/sell advice", and it becomes a portfolio decision brief — no verdict, a fixed schema of decision factors each filled with sourced evidence. Ingest holdings from a broker CSV (Zerodha Console / Groww export), then fan out the single-stock pipeline over each holding.
Per-holding schema
class HoldingBrief(BaseModel):
ticker: str
cost_basis: float
current_price: NumberedClaim # freshness-gated; caveated if stale
since_you_bought: list[Claim] # results, news, events since purchase date
valuation_snapshot: list[NumberedClaim] # vs its own history and sector
thesis_check: Claim # is the ORIGINAL reason you bought it still intact?
flags: list[Claim] # fixed set: results miss, pledge increase, mgmt change, sector headwind
# again: no verdict.The supervisor of analysts (multi-agent)
A single ReAct agent writing bull and bear cases tends to hedge — the same context that supports the bull argument softens the bear one. The reshape: make them separate specialist agents with opposed mandates, coordinated by a supervisor orchestrator. This is the applied form of three lectures at once.
┌─────────────────┐
query ──────────▶│ SUPERVISOR │ plans, dispatches, assembles the brief
└───────┬─────────┘
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ BULL │ │ BEAR │ │ FLAGS │ each: own ReAct loop + tools
│ analyst │ │ analyst │ │ analyst │ mandate: make the STRONGEST
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘ case for its side, sourced
└────────────────┼────────────────┘
▼
┌───────────────────┐
│ SHARED SCRATCHPAD │ evidence pool: computed figures + cited chunks,
└───────────────────┘ written once, read by all — no duplicate retrievalAppendix — GraphRAG for related-party transactions
Some of the most important red flags aren't in any single passage — they're in the relationships: promoter A also controls vendor B, to whom the company pays inflated fees; director C sits on the board of the counterparty in a "related party transaction." Vector retrieval finds the RPT note; a knowledge graph lets you traverse the web behind it.
- LLM entity extraction: pull entities (promoters, directors, subsidiaries, vendors) and typed edges
(
controls,transacts_with,director_of) from the annual report — each edge keeping the page provenance, so a graph-derived flag still cites its source. - Graph primitives + traversal: build the graph, then answer questions retrieval can't — "is any counterparty connected to a promoter within 2 hops?" That query is the red-flag detector.
Appendix — Text-to-SQL over structured fundamentals
Not every question wants a document. "Show me NIFTY 50 names with ROCE > 20% and debt-to-equity < 0.5" is a query over a table, not a passage to retrieve. Load Screener-exported fundamentals into SQLite and add a text-to-SQL path — and this is where the router's multi-source routing (M2) pays off: it sends structured/comparative questions to SQL and narrative ones to RAG.
Appendix — DSPy-tuned briefs
Once the numbers-audit (M8) is solid, you have something rare: a hard, automatic metric for brief quality (faithfulness score + zero audit orphans). That's the exact input DSPy wants. Instead of hand-tweaking the synthesis prompt, express the brief step as a DSPy module and let it compile the prompt against your metric over an eval set of briefs.
- Automated prompt tuning: DSPy optimizes the instructions/few-shots to maximize faithfulness — measurably better than your hand-written prompt, and you'll know by how much.
- Multi-hop reasoning compilation: the extract → compute → cite → synthesize chain is a multi-hop program; DSPy can optimize the whole pipeline end-to-end, not just one prompt in isolation.
Appendix — human-in-the-loop & time travel
The whole design principle is "stay in the loop." So make the loop literal. On top of the M7 persisted threads:
- Interruptions & breakpoints: set a breakpoint before the agent finalizes a brief, inspect the evidence it gathered, and inject a correction ("you missed the pledge disclosure on p.44") before it synthesizes. Human-in-the-loop as a first-class graph feature, not a restart.
- Time travel & state forgery: because every run is checkpointed, you can rewind to any step, edit the state (swap in a corrected line item, force a different retrieval), and replay forward — to see how a single changed input reshapes the brief. Invaluable for debugging why a brief said what it said.