Skip to content
← WorkCase study 04 / 08

Every stock came back 'wait'. The prices were split-adjusted and the earnings were not

StockLens is a local research tool I built for my own investing in Indian listed stocks. A Screener.in export and a company's transcripts go in, and a cited report ending in a stance comes out. The arithmetic is plain Python, a 10-node LLM pipeline writes the narrative, and code rejects any claim whose source id is not in the evidence. One user, 28 stored reports across 18 stocks as of 2 September.

One bank's median P/E went from 8.7x to 20.6x, and its entry level from 33% below the price to 5.5%

Client
Personal project
Role
Sole developer
Year
2026
Stack
PythonFastAPIOpenAI Responses APINext.jsMySQLWeaviateLangfuse
Reading
15 min
Ten bars for ten years of price-to-earnings multiples. The first four are drawn in grey at a quarter of their accent outline and the next three at half, with dashed lines marking a bonus issue and a stock split. The last three bars are solid, where the two calculations agree

Context

I research Indian listed stocks for my own investing, and the loop was always the same. Export about ten years of financials from Screener.in as an Excel workbook, download the latest earnings-call transcript and investor deck, sometimes the annual report, then read all of it and decide whether the company deserves more time. A first read took me about an hour a stock, by my own estimate. StockLens is the tool I built to do that first read with me. Nobody asked for it. It is a personal project with one user, and it runs only on my machine.

The design notes I wrote on day one name three hard parts, and none of them is LLM reasoning. Getting numbers out of PDFs and decks accurately. Not hallucinating financial figures, which means every number cites a document and the arithmetic happens in code. And keeping data consistent across reporting standards.

The constraints were one developer, working with Claude Code assisted programming. About three weeks of activity, from 11 August to 2 September 2026, and I still use it. Local-only by requirement, because traces contain filing text and financial data, so hosted observability was out. That puts nine containers on one Windows machine with 16 GB of RAM. India only: an upload needs an NSE or BSE ticker or it is rejected. The first plan covered US stocks as well and I cut it.

Every report ends in a stance: enter, wait or avoid for a stock I do not hold, and add, hold, trim or exit for one I do. It is a research aid for one person. Nothing on this page is investment advice. I do not name any company I used it on, and every company in the screenshots below is invented.

Approach

The rule that shapes everything is that code computes and the model narrates. Ratios, forensic checks such as cumulative operating cash flow against profit, capital allocation, the valuation histories, a fair-value band, a reverse DCF solved by bisection and the price-action classification are all plain Python with no LLM involved. Each result is registered under a computed: id, so the model cites a number instead of deriving one.

The system is in Figure 1. An upload is hashed, so identical bytes are a no-op. A Screener workbook is recognised by its sheet names, and PDFs are typed by one light LLM call for the whole batch. Statements from a Screener workbook are parsed in code. Statements from a PDF go through text extraction and an LLM, then a cheap structural check: assets must equal liabilities plus equity within 2%. A failure gets one strict retry on the heavy model, and if that fails too the filing is stored and flagged for review. Narrative text is split into paragraph-aware chunks of about 500 tokens and indexed in Weaviate for hybrid BM25 and vector search. Each new transcript gets a summary with guidance, Q&A highlights and a label for management tone.

Architecture diagram in five rows. Row one: Upload, Extract and Index in sequence. Row two: a band labelled frozen evidence bundle, the registry of legal citation ids, holding four boxes: Market, Narrative, News and Python analytics. Row three: a band labelled report graph, 119-line runner, with four numbered nodes in sequence: 7 specialists in parallel, Debate, Verdict outlined in accent as the one heavy call with guards, review and one revise, and Brief. Each node carries an accent tick for the citation gate. Row four: Board, Report and Chat. Row five, under a rule labelled under every OpenAI call, one gateway: MySQL cache, Retry and Langfuse trace.
FIG. 01Filings in, cited report out. The accent ticks are the citation gate, which runs after every node in the graph.

Before any report node runs, three groups of inputs are built in parallel. Market data: a quote, ten years of year-end closes with their split factors, peers and a benchmark. Narrative: 15 fixed retrieval topics, plus up to 5 more chosen by a routing call that decides what kind of business this is. News: four web-search angles in parallel, two searches each, then one merge call with no tools. Together with the Python analytics they are frozen into one evidence bundle, and that bundle is the registry of legal citation ids.

The report itself is a graph of 10 nodes in 4 waves. Seven specialists run in parallel: business, financials, stewardship, valuation, macro, risk and peers. A debate node argues bull against bear over the points that survived. The verdict is the only call on the heavy model. Deterministic guards and a review run on its output, the model gets at most one revise call, and a brief condenses the result into the decision card in Figure 2. After every node comes the citation gate: each point's source type and id must exist in the frozen registry, or the point is rejected and logged. Later nodes cite earlier findings by ids of the form analysis:{node}:{i}, so a reader can click a reason on the card, land on the specialist's finding, and see what that finding rested on. Figure 3 is one such hop.

Dark web app with a StockLens sidebar listing five invented workspaces, from DEMOBANK to DEMOINFRA. The main area is headed Demo Bank Limited with tabs Report, Valuation, Dashboard, Financials, Transcripts, Chat and Sources. A card shows an amber WAIT badge, medium conviction for a prospective buyer, a one-line view, and an amber box reading nothing to do until a price level 5.9% away or the next quarterly results, beside a horizontal fair-value band with a dot for the current price near its low end. A left column titled Why lists five reasons, each ending in a chip naming a specialist. A right column titled What changes the call lists two green ENTER triggers and one red AVOID trigger with dates, then three top risks. Below are five dated news lines from invented publishers and a row of key numbers.
FIG. 02The report opens on one decision screen: stance, reasons and which specialist produced each, price against the fair-value band, and the dated triggers that would change the call. Demo data; the company is invented.
The same report page dimmed behind a dialog titled valuation analyst's finding. It quotes the finding: the fair-value band, its midpoint, and that the core anchors are peer P/B and own historical P/B. Under the label which rested on, a box reads computed:fair_value:mid = 497.2.
FIG. 03Clicking the specialist chip on a reason. The claim resolves to the specialist's finding, which resolves to a number computed in code. Demo data.

Every OpenAI call goes through one gateway function, and no other module imports the SDK. It checks a MySQL cache keyed on a hash of the module, prompt name and version, model and reasoning effort, content and schema. It retries transport errors with backoff, four attempts. It is traced by Langfuse. A truncated response raises an error instead of returning broken JSON. Every output schema is strict: all properties required, nothing extra allowed.

Two smaller pieces hang off the same evidence. A chat agent has six function tools and web search, with its budgets held as counters in code: three round trips, three function calls a round, two searches, and a final round with no tools. Position sizes come from a formula and never from the model. And the stored verdict's triggers are re-evaluated against the live quote and the results calendar every time a page opens, which is the board in Figure 4. There is no scheduler and no alerting, by decision.

Dark page titled Board, subtitled every stock's standing plan against the live price: what fired, what is stale, and the one thing to do next. A table of five invented stocks with columns Stock, Stance, Price vs entry, Since report, Reassess by, Setup and Do next. One WAIT row shows a fired price trigger and says to re-analyze to confirm the move to enter. One says quarterly results should be out, upload them and re-analyze. One is up 26% since the report, tagged sharp rise, and says do not chase a higher price without new evidence. One is a green ENTER row in the buy zone. The last says nothing to do until a price level or the next results.
FIG. 04Each stored plan against the live price, with the rows that need attention first. Demo data; the 'Do next' text is the app's real output for these invented inputs.

Decisions

[01]Design decisionOne share basis for prices and per-share figures, and code reviews the verdict
Decision
Keep Yahoo's closing prices as they come, and adjust every per-share denominator to today's share count using the split factors in the same data frame. Build the fair-value band from core anchors only: a peer or own-median multiple times EPS, or times book value for lenders. The analyst target price becomes a reference level that can be cited but never sets a bound, and an anchor outside 0.5x to 2x the median of the others is dropped. The hurdle rate went from 15% to 12%. After the verdict, code lists problems and sends them back to the model once. Whatever remains is enforced in code, which may re-anchor a trigger or cut conviction but never flips a stance.
Why
Every stock came back 'wait' with an entry level the price was never going to reach. Yahoo's close is always adjusted for splits and bonus issues, and my code divided it by EPS and book value as reported. For one large private-sector bank, a bonus issue and a stock split had halved and then quartered every earlier year's multiples. Its own history said it had always been cheap, so today's price looked absurd.
Tradeoff
Own-history multiples changed for every workspace, so verdicts from before 1 September are not comparable with later ones. The review can add one more heavy-model call to a run, and on its first outing it spent that call partly on a false positive, which I come back to at the end.

The same bank, in the same workspace, from the stored runs before and after the fix:

Before, 30 AugAfter, 1 Sep
Own median historical P/E8.73x20.59x
Own median historical P/B1.27x3.20x
EPS growth a year, FY17 to FY26-2.07%+14.24% on today's share count
Upper end of the fair-value band set bythe analyst target priceown median P/B times book value
Price trigger's distance from the live price32.9% below5.5% below
Non-price triggers carrying a date0 of 33 of 3
Stance and convictionwait, mediumwait, medium
Review recordnone, the loop did not exist2 issues, 1 revise call, 0 enforced

The stance did not change, and the fix was never meant to change it. What changed is that "wait" now comes with a level the price could plausibly reach and triggers that carry dates. Only runs from 26 August onwards store the distance to the nearest upgrade trigger. In the seven before the fix it was 2.1%, 16.0%, 16.3%, 17.7%, 19.2%, 31.5% and 32.9%, so six of seven sat beyond the 15% limit the review now enforces. In the two runs that followed, on 1 and 2 September, it is 5.5% and 2.0%.

[02]Design decisionParse the Screener workbook in code, keep the LLM as a fallback
Decision
Screener workbooks are parsed deterministically from the raw Data Sheet, and the derived lines (total expenses, operating profit, EPS, balance-sheet totals, trailing twelve months) are rebuilt with Screener's own formulas. The per-sheet LLM path stays only as a fallback for workbooks that do not fit.
Why
The first version extracted sheet by sheet with an LLM. In many downloads the formatted sheets are formulas with no cached values, so the reader saw empty cells and the LLM path silently extracted nothing. The formatted P&L also carries 'Best Case' and 'Worst Case' forecast columns. One of them was read as a period, and a fake -24.5% trailing revenue growth made it all the way into a final verdict. By my notes that path took 4 sequential LLM calls and 259 seconds on one workbook. The traced run in Figure 5 has no extraction call at all.
Tradeoff
The parser is tied to Screener's workbook layout. A layout change breaks it, where the LLM path would have absorbed the drift. A whitelist on period labels is the second line of defence against forecast columns.
[03]Design decisionCheap model first, escalate once, heavy model only for the verdict
Decision
The seven specialists, the debate, the brief and chat all run on the light model, gpt-5.6-luna. Only the verdict runs on the heavy one, gpt-5.6-terra, at medium reasoning effort. A light node whose output fails validators in code is re-run once on the heavy model, with the problems listed in its payload.
Why
Three configurations in three weeks. The first was an accident: through .env drift the 'heavy' tier was the cheapest model, and the light tier was an older model that was both weaker and dearer. The second put the heavy model on financials, valuation, debate, verdict and chat, and my notes put a first run at about $0.60 to $0.80 at list prices. The third is this one, after a review found that three heavy calls plus the debate were about 75% of a run's cost, for judgment that the validators and the verdict pass already police.
Tradeoff
The light model's long-context recall is weak, so the annual-report extraction retry still escalates. Any change of tier or effort invalidates that module's cache, so the next run of every workspace is cold and full price. And the saving is an estimate: my notes say about $0.45 down to $0.15 to $0.20 a run at list prices, and I have not run the cost report since the change.

The validators that trigger an escalation are all structural. More than 40% of a node's cited points reference ids that do not exist. No point survived even though evidence was there. A required field is blank. Or the node said "unknown" where its evidence slice carried the number. In the two stored runs that followed the change, on 1 and 2 September, no node escalated. The run traced in Figure 5 shows the same: one call per node, 19 of its 20 calls on the light model, and no revise call after the verdict.

[04]Design decisionA 119-line graph runner, not an agent framework
Decision
Nodes are plain callables with declared dependencies, checked with Kahn's algorithm and run in topological waves on a thread pool. A failed node degrades the report instead of sinking it, and cancel is polled between waves. The whole runner is 119 lines.
Why
A graph framework is the usual way to get guaranteed termination, observability and typed state. At decision time this project had exactly two agentic loops, one user and no concurrency across tenants, so hard loop caps in config, dataclass state and a logged stop reason gave the same guarantees.
Tradeoff
No checkpointing, no resume, no pause for a human. My own notes said to revisit if parallel fan-out arrived. It arrived within a day, the graph later grew to 10 nodes, and the answer stayed a hand-rolled runner. LangGraph was rejected on paper. I never built or measured the alternative.

I had used LangGraph on VeriStream AI a few months earlier, so this was a choice and not unfamiliarity. For a fixed graph with one user it was the smaller thing to own.

[05]Design decisionSelf-hosted Langfuse on day one, not home-built logging
Decision
The first build shipped its own LLM call-log table, ops endpoints and an ops dashboard page, as my plan specified. I removed all three the same day and moved tracing, cost and prompt versioning to a self-hosted Langfuse.
Why
What I wanted from observability was a purpose-built platform, not homegrown bookkeeping. Self-hosting keeps filing data on my machine, and the OpenAI drop-in wrapper made it close to a one-line change. Live prompts are served from Langfuse with local prompt files as seeds and offline fallbacks, and the prompt version is part of the cache key, so publishing a new prompt invalidates stale cached answers.
Tradeoff
Six more containers on top of the app's three, with ClickHouse taking most of the memory. Prompt edits happen in a UI, so the local fallback files go stale unless I keep them in sync. And its cost figure under-counts: it prices cache-write tokens at zero and never sees the web-search fee, so it showed $0.22 for a run my notes put at about $0.45. I wrote a separate script that re-prices from ClickHouse.

The traces paid for themselves on day two. One review of a run produced most of the pipeline's current shape, and Figure 5, a run I traced on 17 September 2026, still shows it. Reasoning effort is set per module, after the traces showed reasoning tokens were more than half of a run's cost: by my notes document detection had taken 11.4 seconds and 576 reasoning tokens, and in Figure 5 it takes 2.7 seconds and none. A sequential news agent with up to five refine loops, whose sweeps ran 52 to 113 seconds each, became parallel angles and one merge call. In the trace of 12 August that was three angles of 14 to 17 seconds each. In Figure 5 it is four angles of 32 to 46 seconds, which cost 46 seconds of wall clock between them. A fifth angle runs only after a large price move, as it did here. Ticker resolution by LLM and web search, once 24 seconds on the critical path, is absent from the figure: the detection call read the exchange symbol off the filing cover.

Gantt-style chart of 20 LLM calls on an axis of 0 to 300 seconds since the first call. In grey, the evidence stage: detection, 2.7 seconds from zero; four news angles all starting near 4 seconds and lasting 41.7, 45.7, 31.9 and 35.8 seconds; a transcript summary of 18.7 seconds from 29 seconds; peer discovery, 23.2 seconds from 48 seconds; a news merge of 5.3 seconds; a price-move news angle of 47.2 seconds from 55 seconds; and routing, 18.2 seconds from 87 seconds. In dark ink, seven specialists all starting at 108 seconds: business 39.0 seconds, financials 35.3, stewardship 28.8, macro 31.2, risk 35.7, peers 21.7, and valuation far longer than the rest at 96.6. In accent, in sequence from 205 seconds: debate 20.1 seconds, verdict 56.8 and brief 29.3, ending at 311 seconds.
FIG. 05One report run from the tool's own traces, 17 September 2026: 20 LLM calls in 5 min 11 s. Six of the seven specialists finished within 39 seconds. The seventh took 97, and the graph waited for it.

Results

20.6xOne bank's own median P/E after the share-basis fix. It had been computed as 8.7x
280Claims rejected by the citation gate across 28 stored runs, against 1,522 kept
311 sOne traced report run, first LLM call to last: 20 calls, 19 of them on the light model
28Reports stored across 18 stocks as of 2 September, none with a failed node

None of those is a business outcome, and there is no measured one. There is no returns tracking and no usage analytics, and the only user is me. What I can say from use is that a first read on whether a stock deserves more research went from about an hour to a few minutes, by my estimate and never timed. The part I lean on most is the read on earnings calls: the per-transcript summary with its management-tone label, and the stewardship specialist that reads guidance history across calls.

As of 2 September, the 28 stored runs covered 18 stocks and 65 uploaded files. Their stances were 22 wait, 4 enter and 2 avoid. All six calls that were not "wait" date from 17 to 20 August. The 12 runs from 21 to 30 August were all "wait", which is what sent me looking for the bug in decision 1. The two runs straight after the fix, on 1 and 2 September, were also "wait". The difference was the distance to the entry level, as above. The run in Figure 5, on 17 September, is not in those counts. It came back "avoid" at medium conviction, the first call since 20 August that was not "wait". That is one run, and I do not claim the fix ended the streak. None of the 28 runs had a node error. The rejected and kept counts cover slightly different sets of nodes, so treat 280 against 1,522 as a rough proportion.

The traces behind Figure 5 also show the gate at work. Comparing each node's output with what the next node received, eight points were dropped on the way. Four cited a document chunk id with a few characters wrong, two wrote news_digest where the legal id is news:digest, one shortened a computed id, and one invented an upstream id. Every one of those claims read as plausible.

The run in Figure 5 took 5 min 11 s from the first LLM call to the last, which excludes upload, parsing and indexing. It is one run, and the time is not evenly spent. The valuation specialist took 96.6 seconds, on the same model and with a similar output size as its six neighbours, which all finished within 39. The debate waited almost a minute on that one call, and I do not know why it was slow. The verdict, the only heavy-model call, took 57 seconds, and the price-move news angle ran for 47 seconds after the merge. By my notes an early version of the pipeline took 6 min 17 s for 17 calls. After the trace review on day two, the run of 12 August made 12 calls in 85 seconds. The run in Figure 5 makes 8 calls that one did not: a fourth news angle, the transcript summary, peer discovery, the price-move angle, routing, the stewardship and peers specialists, and the brief. That is one run each, on different companies and different models, so I do not read the three figures as a trend.

Cost is where I have to be most careful. The per-run dollar figures in the decisions above are list-price estimates from my own notes. What OpenAI actually billed across my whole personal organisation from 8 August to 17 September 2026 was $2.17, and that account also serves other projects, so it is an upper bound for this one. The export shows no spend after 2 September, so it does not include the run in Figure 5. Nearly all of it is web-search fees. Every daily total is within a fraction of a cent of a whole number of cents, which at one cent a search is about 216 searches and almost nothing else. Token charges came to close to zero, and I do not yet know why. So I can say what I was billed. I cannot say what a run costs.

The Figure 5 run shows the tracing tool's under-count from decision 5 directly. Langfuse priced the run at $0.10, of which the single heavy-model verdict call was $0.046. About 152,500 prompt tokens in the run were logged as cache writes and priced at zero, and the 14 web-search tool calls the run made, 13 searches and one page open, do not appear in the figure at all.

Two smaller results. The report prompts grew to 18.3k words by my notes and were cut to 9,873, counted. And after a friend who works as an equity analyst read one exported report and found it far too long, with valuation discussed in five places, I added a client-brief layout: 3,235 words and 18 footnotes against the full report's 6,875 and 58, for the same sample company.

Looking back

The share-basis bug was there from the start: the ten-year price fetch asked for adjusted prices and nothing adjusted EPS. My tests could not catch it, because every one of them fakes the market data. It surfaced as me complaining that every stock came back "wait", not as a failing test. One test on a recorded price history that includes a split and a bonus issue would have caught it on day one.

It had company. A "10-year median P/E" was a 3-year one, because the price history fetch covered three years. Dividend yield read as 86% after the market data library began reporting percent instead of a fraction. A PEG of 0.09x was printed next to "Valuation: expensive", because the library's PEG used one quarter's year-on-year growth. My tests wrote events into the production Langfuse project until the test config disabled tracing. And a leadership change at one company never reached the verdict: five nodes received no news at all, and the sweep prompt told the model to skip routine board-meeting notices, which is exactly how such a change is disclosed.

The run in Figure 5 shows the share basis is not finished either. The fair-value band multiplies by the newest annual EPS line in the workbook, although my own code comment says the band is anchored on trailing EPS. That export had no EPS line for the latest full year, so the band sat on earnings a year old while trailing EPS was about 2.5 times higher. And the dilution check skips a bonus issue only when the workbook carries a bonus-shares line. This one did not, so a jump in share count that was mostly a bonus or split, already recorded in the split factors the price data carries, was flagged as dilution. "Dilution" is named in that verdict's basis for the stance.

The review loop's first real run shows its weak spot. The check for governance headlines is a regex that includes the central bank's abbreviation. It flagged a macro headline that merely quoted the RBI as an unaddressed regulatory item, alongside one true positive, and the pair triggered a heavy-model revise call. A regex cannot tell a regulator named in a macro story from a regulatory action against the company.

I was careless with the project's own history. I kept almost no record of how the code changed over three weeks, and the reasoning behind it survives only because I wrote each decision down as I made it, 11 notes in all. Nothing runs the tests automatically, although they take 19 seconds, and the frontend has none. And there are no backups: the 28 reports and 65 uploads exist on one machine.

What I would change. Keep a proper history of the work from the first day. Fill the golden extraction set and the retrieval query set before adding features, since both harnesses are already written. Add the split-and-bonus integration test. Make the dilution check read the split factors, and fall back to trailing EPS for the band when the latest annual line is missing. Record each run's cost and latency per node in the run itself, so cost claims do not depend on a tracing tool that under-counts, and keep the output of every evaluation run so that before and after are measured and not remembered. Replace the governance regex with something that reads the headline. And run the tests automatically on every change, which at 19 seconds is nearly free.

[02] Contact

Want to talk? Say hello.

A project, a question, or just to compare notes. I read everything and write back within a couple of days.