Never Average Two Balance Sheets: A Stock Analysis Stack With a Bounded LLM
A stock valuation stack that never averages two balance sheets: Yahoo, SEC and FMP data merged by priority, a currency gate and a local LLM kept in its lane.
This article is published in English.
Sources: Never Average Two Balance Sheets
This case study follows a stock analysis tool that anchors a local Ollama-based agent to a deterministic valuation engine the model can't recompute. The tool targets one answer: what a business is worth compared with what it costs today. It runs six valuation models and five diagnostic scores, then issues a single verdict on a five-step scale, covering listings in India, Europe and the US.
The original build was a single 1,700-line Python process. It ran fine, yet months in it couldn't answer the one question that counts for a valuation tool: what was the verdict on a given company last March, and what changed since? There was no history, a lone data provider and a cache that disappeared whenever the process restarted. Splitting it into five services, and weighing what each boundary bought, is the subject here.
Drawing the lines
The five services have sharply separated duties. Only marketdata has internet access. Only valuation produces a number. Only gateway is exposed to outside traffic.
Blast radius, not scale
Microservices for a hobby project often earn a smirk. The motivation here was practical: different kinds of failure were tangled together and each one cost time. Traffic isn't a factor, because one person uses it. The goal is containing damage. A renamed Yahoo field affects a single pod, which logs warnings. A hung model doesn't stop an analysis from returning. Testing another discount rate means redeploying roughly 600 lines of pure, I/O-free functions.
Common code lives in a shared library: configuration, ORM models, a Redis cache that falls back to process memory, an HTTP client that retries, and a factory that equips every service with the same /health/live and /health/ready routes. Five services all report health in one identical way.
One request from start to finish
A call to GET /api/analyze reaches the gateway, which fans out to the other services and stitches the answers together. It computes nothing itself. Three upstream calls happen so that the browser needs only one:
@app.get("/api/analyze")
def analyze(q: str, market: str = "US", refresh: bool = False):
snap = _up("marketdata", "/snapshot",
params={"q": q, "market": market, "refresh": refresh})
rates = _up("marketdata", f"/rates/{market}")
result = _up("valuation", "/analyze", method="POST", json={
"symbol": snap["symbol"], "market": market,
"snapshot": snap["data"], "rates": rates, "persist": True,
})
# Provenance travels with the numbers, so the UI can show who said what.
result["sources"] = snap.get("sources", [])
result["disagreements"] = snap.get("disagreements", [])
return result
The last part of that flow, provenance traveling alongside the numbers, turned out to be the most valuable outcome of the whole rewrite.
Three sources, and nothing gets averaged
Priority beats consensus
The monolith trusted Yahoo Finance alone. Yahoo's fundamentals lag and have gaps, and they're weakest exactly where it matters most: smaller Indian and European listings.
Two more opinions were added. SEC EDGAR's XBRL API covers US companies with no API key, and its numbers are exactly as filed, so it settles ties. Financial Modeling Prep supplies global coverage and is strongest where Yahoo is weakest.
With three sources there are three answers, and averaging them is the tempting shortcut. Resist it. Blend two balance sheets and the result is a document nobody ever filed.
The priority order is explicit:
# SEC is as-filed, so it outranks everything in the US.
PRIORITY_US = ("sec", "fmp", "yahoo")
PRIORITY_OTHER = ("fmp", "yahoo")
The highest-priority source that actually has a field wins outright. Every other value is retained for display together with the relative spread:
def _dispersion(values: list[float]) -> float | None:
"""Relative spread across sources. 0.0 means they agree exactly."""
clean = [v for v in values if v is not None]
if len(clean) < 2:
return None
scale = abs(statistics.median(clean))
if scale < 1e-9:
return None if max(map(abs, clean)) < 1e-9 else 1.0
return (max(clean) - min(clean)) / scale
Anything beyond 5% is flagged as a disagreement and shown beside the number. If SEC reports net income of 130 and Yahoo says 100, a silent 115 would hide the conflict. The better outcome is seeing both and knowing which one the model used.
The currency gate
A trickier trap involves currencies. Infosys is listed on the NSE and priced in rupees, yet its accounts are in dollars. London tickers are quoted in pence while market capitalization is in pounds. If a dollar-reporting source is allowed to contribute a statement field for a rupee-priced stock, the P/E lands roughly 80 times off and still looks believable.
So a check runs before priority is even considered:
for name, data in sources.items():
rc = (data.get("reporting_currency")
or data.get("currency") or base_currency or "").upper()
allowed_statements[name] = (not base_currency) or rc == base_currency.upper()
A source whose reporting currency differs from the trading currency gets no vote on statement fields at all. The rule is blunt and correct, since losing an opinion beats averaging two currencies.
One consequence: every multiple has to be recalculated once the winners are chosen. Mix EDGAR's net income with Yahoo's P/E and the page disagrees with itself in a way nobody spots for weeks. P/E, P/B, P/S, EV/EBITDA, ROE and the margins are therefore all recomputed locally from the chosen inputs.
Stop hardcoding the risk-free rate
Two assumptions under every DCF
A discounted cash flow depends on a growth rate and a discount rate. In this tool the discount inputs began life as a dictionary literal, holding a risk-free rate, an equity risk premium and a tax rate per market:
"US": {"rf": 0.042, "erp": 0.045, "tax": 0.21},
Typed once and never revisited, such numbers drift out of date for good. The replacement pulls one FRED series per market: DGS10 for the US, INDIRLTLT01STM for India and an OECD series (IRLTLT01..M156N) for Europe. Results are cached for a day, saved with their observation date and clamped to reasonable limits:
def risk_free(market: str, fallback: float) -> dict:
"""{rate, source, observed_on, series}. Never raises."""
series_id = SERIES.get(market)
static = {"rate": fallback, "source": "static",
"observed_on": None, "series": None}
if not series_id or not enabled():
return static
...
Notice the shape of that function. Every integration in the system returns a degraded answer instead of raising. No FRED key means static rates. No FMP key means two sources instead of three. No Redis means an in-process dict. The system reports which path it took through GET /api/providers rather than failing when a nice-to-have is missing.
Storing the rate per observation date matters more than it seems. Six months later, when a verdict has changed, you can tell whether the business moved or the discount rate did.
Holdings are derived, never stored
The portfolio service keeps transactions and nothing else, and the ledger is the only truth. Positions are recomputed from it on every request: quantity, cost basis, realized and unrealized gains, dividends, weights, and XIRR for each position and the whole account.
That costs more work per request and is obviously correct. Enter a trade wrongly in March, fix it in September, and history repairs itself. Store the position instead and you'd patch a derived number forever.
XIRR is the one honest return figure for a portfolio that keeps receiving deposits, because a simple percentage gain credits you for timing you never chose. The solver tries Newton's method first, falls back to bisection when Newton wanders off, and returns None when the cash flows really don't determine a rate.
An analyst that can't overrule the analysis
Deterministic at both ends
The last component is a LangGraph agent backed by a local Ollama model. Making it clever was never the goal. The harder design problem was preventing confident mistakes, and three restrictions handle it.
It never touches a tool. Every fetch and calculation happens in gather, before the model is invoked. The model can't request data, so it can't loop, wander or invent a source.
It reads a fact sheet, not JSON. The sheet holds about forty labeled numbers in under 4KB:
VALUATION
blended fair value: 1402.11 INR (confidence medium, model spread 0.43)
per model: dcf=1610.22, epv=1104.50, graham=1288.31, gordon=1377.90
margin of safety: -8.4% - Trading above fair value
growth assumed: 11.0% (basis: revenue YoY 11.0%) discount rate: 11.2%
SOURCE DISAGREEMENTS (treat these figures as uncertain):
net_income: fmp=261.0B, yahoo=248.5B (spread 4.9%, using fmp)
A model with 8B parameters handles that compact sheet well and a 40KB dump poorly. The second block is worth noticing: disagreements found during reconciliation are included in the prompt, so the model knows which numbers are uncertain.
It gets at most one rung of freedom. The engine's verdict is the anchor:
def _clamp_to_engine(model_verdict, engine_verdict):
gap = SCALE.index(model_verdict) - SCALE.index(engine_verdict)
if abs(gap) <= 1:
return model_verdict, None
capped = SCALE[SCALE.index(engine_verdict) + (1 if gap > 0 else -1)]
return capped, (f"model said '{model_verdict}', more than one rung from "
f"the engine's '{engine_verdict}' - capped at '{capped}'")
The model may argue but may not overrule a quantitative model it can't recompute, and each attempt is recorded, itself a signal worth reading.
A second pass has the model attack its own draft for unsupported claims, looping if the critique would change the verdict. If the critique still holds after the loop budget runs out, the stated conviction is multiplied by 0.7.
When Ollama can't be reached, the graph skips to finalise and returns the engine's verdict with status: engine_only. The endpoint still returns 200. A missing model produces a degraded answer, not an outage.
What the manifests encode
Everything lives in one namespace, stockanalysis, on a local k3s cluster.
Probes are deliberately unaware of the model:
startupProbe: { httpGet: { path: /health/live, port: http },
failureThreshold: 30, periodSeconds: 5 }
readinessProbe: { httpGet: { path: /health/ready, port: http }, periodSeconds: 15 }
livenessProbe: { httpGet: { path: /health/live, port: http }, periodSeconds: 30 }
Were the liveness probe to test Ollama, a model that simply hadn't been pulled would register as a crash, and kubelet would keep restarting the pod without end. Whether the model is healthy is asked deliberately through /api/agent/model.
The agent deliberately gets one replica. One Ollama process on the host serves one model, so extra pods only wait in line on the same GPU while doubling memory needs. Autoscaling there would waste resources. The part to scale is valuation: stateless, pure computation, no I/O.
Outside traffic reaches the gateway alone. The rest are ClusterIP services behind a default-deny ingress policy. The agent is proxied via /api/agent/* and has no public surface, which makes that proxy the natural place for authentication later. Total requests for the stack come to about 1.1Gi.
Four things that broke, none of them architectural
- containerd versus dockerd. Rancher Desktop installs a
dockerCLI under both runtimes, so a printed help screen settles nothing. With containerd, images have to be imported into thek8s.ionamespace. Built elsewhere, they leave every pod stuck inImagePullBackOff, complaining about a registry that was never contacted. - CoreDNS can't resolve the host. The URL
http://host.rancher-desktop.internal:11434used forOLLAMA_BASE_URLfails because that hostname is defined in the WSL distro's hosts file, not in cluster DNS. Every analysis would have returnedengine_onlywithout any error, running but incomplete, the nastiest kind of bug. The cure is to resolve the WSL default gateway address during deployment. - Not enough memory. With two replicas of each service, requests totaled 2.24Gi before k3s's own pods were counted. A single replica each cut it to 1.05Gi. Another trap: a rolling update with
maxSurge: 1, maxUnavailable: 0deadlocks a one-replica deployment on one node, because the new pod needs memory the old pod hasn't released. - PowerShell 5.1 and encodings. It reads
.ps1as ANSI unless a BOM exists. Em dashes in comments turned into mojibake and took the parser down with them.
All four cost more time than the architecture did.
Known debt
- The reconciliation tolerance is one global 5%. It should vary by field, since a 5% gap on revenue is alarming while on interest expense it's noise.
- The agent's fact sheet is assembled by hand with string formatting. The shape is right, but it ought to be generated from the schema the valuation service publishes, so the two can't diverge.
- There's no authentication. For a single-user tool bound to localhost that's defensible, and the moment it moves elsewhere it isn't.
The pattern underneath
Each component should degrade instead of failing, and should state what it did. Three data sources shrink to two with no fuss. FRED turns into a static table. Redis turns into a dict. Ollama turns into silence, and the engine still gives its answer.
At no point does the system overstate what it knows, and for a tool whose output could drive a real-money decision, that outweighed every other design choice.