From news bursts to story graphs, with local LLMs

This post traces how tweets or news stories spread through the media and whether we can visualise the spread of news or items visually. Typically, items start with small bursts and then become full-blown stories as more people connect and discuss it online.

The plan was simple to state:

posts → people, places, organisations and claims → a graph that changes over time → “what’s happening?”

Originally, I wanted to use X f(ormerly Twitter) and listen to tweets live, but the cost of the X stream is prohibitive, so I switched to Bluesky whose whole public stream is free to read.

The the idea was to identify news/event bursts and then see how the event or news item actually becomes a full-blown story. The interesting part is what happens over the next day. So the project was created in two halves 1) finding bursts, and 2) following each burst into a story.

The setup

  • Data: Bluesky’s public stream through its free Jetstream feed, English posts only. This was done for four days from 18 to 22 October 2026, and then frozen so every number below comes from the same recording: about 8 million original posts and 7 million reposts.
  • Everything runs offline on the recording. Recording happens once; every experiment replays the saved files. That made it possible to change a threshold and compare results on identical data.
Flowchart illustrating data capture and replay processes for Bluesky Jetstream, detailing steps for polling news feeds, saving JSONL files, freezing data collection, and replaying files for analysis.
JobModel (all local)Why this one
Find names in every postGLiNER, a small entity modelfast enough for every post, about 140 posts a second
Judge whether posts say the same thingMiniLM sentence embeddingsabout 7,000 posts a second
Extract facts and claims for a storyGLM-4.7-Flash, via Ollamathe fastest model
Tamil news, merging names, captionsGemma 4 31B, via Ollamabest quality , and it reads Tamil
Store the graphNeo4j Community editionfree and local

How it works

  1. Record the Bluesky stream to hourly files.
  2. Find the names in each post: people, organisations, places, facilities, products and events.
  3. Detect bursts: a set of names that suddenly many more people are writing about than usual.
  4. Follow the story: collect its posts from 3 hours before the burst to 24 hours after.
  5. Extract facts and claims at 12 points in time with a local model, each tied to the posts that support it.
  6. Merge duplicate names, store everything in Neo4j, and draw the story as an interactive page that plays the 12 steps in order.
Flowchart illustrating data sources, capture and replay methods, and entity processing in a system. It includes information about Bluesky Jetstream, news RSS feeds, data recording, and entity cache operations.
Flowchart illustrating a process for tracking and analyzing story bursts, including steps for identifying co-mention graphs, tracking event writers and reposts, managing facts and claims, and generating knowledge graphs with outputs.

Part 1: Bursts, or finding the moment a story breaks

1. Counting hashtags finds bots

The obvious first step is to count hashtags and words and flag whatever grows fastest. In the first morning of data, the top trends were:

TrendPostsAuthors
roadworks (a UK traffic bot)5641
#kickban1971
#greatwhitemike1581
nebraska4332

Three of the top four were single accounts. Everything from here on counts distinct people, not posts, and nearly identical texts count once, because spam networks post the same template from dozens of accounts (one sent the same advert from 82).

2. Names, not hashtags

Only 11% of Bluesky posts carry a hashtag, names were needed inside the text. Gliner and Spacy entity extractors were compared on 200 posts, using Gemma 4 31B as the reference. Gemma is too slow for every post, at 0.3 posts a second, but it is accurate.

ExtractorPrecisionRecallTypes rightSpeed
spaCy (small)0.770.670.59about 460 posts a second
GLiNER0.480.810.72about 160 posts a second
GLiNER with a junk filter0.730.720.75about 160 posts a second

Unfiltered, GLiNER labelled “I’m 49”, “Dinner” and “He” as entities. A small filter (no pronouns, no dates, no common phrases in lower case) fixed most of it. Over a full hour, 53% of posts named nothing, 24% named one thing, and only 23% named two or more. Short posts mostly mention one part of a story.

3. One story, seven events

The initial detector linked names that people mention together. On the morning of 18 October, Ukrainian drones struck targets in St Petersburg: an oil terminal, a VK data centre, an Ozon warehouse and a bridge. The detector reported seven separate events. One post said “Ozon”, another “VK data center”, a third “oil terminal”, and almost nobody named two of them in one post.

4. Need for embeddings

Names alone could not tell which posts belonged to the same story, because most posts name only one part of it. I needed a way to compare what posts say, not just which names they share.

An embedding turns a post into a list of numbers, so that posts with similar meaning get similar numbers even when they share no words. “Drones hit an oil terminal in St Petersburg” and “Ozon warehouse on fire after the attack” come out close together; a post about a football score does not. A small local model (MiniLM) does this at about 7,000 posts a second, so it can be applied freely. Reading every post with a language model would understand meaning better, but Gemma manages 0.3 posts a second while 16 to 44 arrive every second.

I ended up using embeddings in four places:

WhereWhat went wrong with names aloneWhat embeddings did
Grouping a burst into one eventThe strike came out as 7 events, because “Ozon”, “VK data center” and “oil terminal” were rarely named togetherNames whose posts read alike were linked, and the strike became one event with 8 names
Following an event from one window to the nextWhen later posts used new names (“Baltic oil terminal”, “Saint Petersburg”), the strike restarted as a new eventThe new group was matched to the existing event by meaning, and the strike stayed one event for 105 minutes
Choosing the posts to read for an eventA post that only said “Nintendo” or “Korea” was pulled into unrelated stories: a 22 year old game, a Korean sports teamA post sharing only one name with the event must also read like the event
Following a story for a dayEvery passing mention of a name would have joined the storyPosts naming one of the story’s names must also be close in meaning to the story’s own posts

Two limits showed up. The threshold matters: at a similarity of 0.45, unrelated political stories merged, because political posts all read alike to a small model; at 0.65 the strike split again; 0.55 worked. And embeddings cannot rescue a story whose names are too general, which comes up in Part 2.

Each change was tested on the same three hours of data:

RunChangeEvents foundWhat happened
1names mentioned together173the strike came out as 7 events, a college football game as 7
2linking posts that read alike35the strike disappeared altogether (see the next section)
4writers counted per event60the strike became one event with 8 names
7thresholds tuned61the strike lasted 105 minutes as one event, and unrelated politics stopped merging

5. The burst formula

Putting the pieces together, this is how a burst is scored. Every 5 minutes, for every name, the detector looks at the last 15 minutes:

  • authors: the distinct people who wrote or reposted a post naming it
  • total: the distinct people active at all in those 15 minutes
  • share: authors divided by total

Each name has its own baseline: its usual share, and how much that share normally varies. Both are running averages that remember roughly the last 6 hours (a half life of 72 steps of 5 minutes):

Each part has a job:

  • Share, not count. Activity on Bluesky roughly triples between the US night and morning. Counts would make every name look like a burst each morning; shares only move when the conversation does.
  • The expected term allows for ordinary chance: a name expected 100 times can easily turn up 110 times.
  • The (total × √var)² term asks more of names that are always busy and always vary, such as Trump.
  • The + 1 stops a name never seen before from scoring infinity with two people, while a new name with 20 people still scores about 20.
  • The baseline is updated after scoring, so a burst is never compared with itself.

A name is bursting when at least 8 people named it in the window. Bursting names are grouped into events (by co-mention and by embeddings), and an event must then pass two more tests from the previous section: at least 3 people wrote original posts about it, and for at least one of its names the writers alone score at least 2 on a second baseline that counts writers only. The first 2 hours of any run only build the baselines.

Two examples. At 06:45 IST on 18 October, Ozon was named by 21 people in 15 minutes where about 0.8 would be normal, giving z = 7.6, and VK data center by 11 people where almost none was expected, giving z = 9.1: both bursts. For a constantly busy name, take an illustration: a usual share of 3% of 15,000 active people gives an expected 450; if its share normally varies by 0.4 points, the spread term adds 60, so the denominator is about 64. Then 520 people give z = 1.1, which is ordinary, and it would take about 770 to reach z = 5.

7. The burst is short

Every tech event the detector found on Monday 19 October lasted between 10 and 105 minutes. That is when people suddenly pile in. But the stories themselves ran for a day:

Bar chart showing social media post activity over time for three stories: 'St Petersburg drone strike', 'Prop 40 and Sergey Brin', and 'Nvidia and warehoused GPUs', with indicated burst detection periods.

The shaded band is all the detector saw. For the strike, the biggest wave came six hours later, in the Indian evening, as casualty figures and claims arrived. Prop 40 had a second wave the next evening. A graph built from the burst alone had 2 to 5 checkpoints: a snapshot, not a story.

Part 2: From a burst to a story

1. Following a story for a day

So the burst became a seed. Each detected event gives its main names and the average embedding of its posts. One pass over the recording, from 3 hours before the burst to 24 hours after, collects every post that names two of the story’s names, or names one and reads like the story, along with its repost count. All stories are followed in the same pass:

StoryPosts followedReposts of them
Steam Next Fest2,3127,763
Nvidia and warehoused GPUs7943,398
St Petersburg drone strike4143,784
Prop 40 and Sergey Brin3424,044
Amazon GPUs2862,965

2. Twelve checkpoints, facts and claims

The followed posts are cut into 12 slices with similar numbers of posts, so busy periods get finer steps. For each slice, GLM-4.7-Flash reads the most reposted posts (and the first one) and returns:

  • Facts as subject → verb → object (“Ukrainian drones → attacked → Ozon warehouse”), each citing the posts that state it. A fact citing no real post is dropped.
  • Claims, with who makes them: “135 drones were shot down” (the regional authorities), “our services are working normally” (VK), “the warehouse was used by the Russian defence ministry” (Ukraine; Ozon denies it).

Separating facts from attributed claims turned out to be one of the most useful parts. A story is mostly claims: casualty figures from three different sources, accusations, denials.

Infographic showing the impact of a drone attack in St. Petersburg, highlighting targeted infrastructure such as oil terminals, data centers, and warehouses.

3. Many names for one thing

Extraction runs slice by slice, and the same thing comes back under different names: “Vijay”, “CM Vijay”, “C Joseph Vijay” and “TN CM”; “IMD”, “India Meteorological Department” and “weather department”. Three layers fixed most of it:

  1. Prevention: each slice is given the names already used for this story, with an instruction to reuse them, plus a short list of preferred verbs (“attacked”, “located in”, “deployed to” and so on).
  2. Clean up in code: “X says Y” becomes a claim by X, not a fact; a fact linking a thing to itself is dropped; “VK data centre was hit by a drone” is turned round to “drone hit VK data centre”.
  3. One Gemma call per story groups the names that mean the same thing and picks one, while being told that a district is not its city (North Chennai is not Chennai).

On the Chennai rain story (below), this cut 127 different names to 70, and 42 different verbs to 11. Gemma also knew that “Kovai” is Coimbatore and that “the Tamil Nadu Weatherman” is the blogger Pradeep John. It made mistakes too: it merged “weather centre”, which in Tamil reports usually means Chennai’s regional centre, into the national IMD.

4. Captions that say only what’s new

My first pages showed each slice’s summary, and every summary began by restating the whole story. So Gemma now writes one fixed sentence for the story, and one sentence per step with only what is new. For Prop 40, California’s proposed tax on billionaires:

Story: Users debate California’s proposed billionaire wealth tax (Prop 40) and criticise tech billionaires’ ties to Donald Trump and Mar-a-Lago.

  1. Users suggest renaming Mar-a-Lago and discuss Props 41 and 42.
  2. Sergey Brin reportedly spent over $100 million against Prop 40 and dined with Trump at Mar-a-Lago.
  3. Reports that Mark Zuckerberg moved to Florida and Gavin Newsom vacationed with Brin.
  4. Musk, Thiel and Zuckerberg reportedly oppose Prop 40; Bernie Sanders supports it.
  5. A poll suggests Prop 40 faces a “spectacular collapse”.
  6. Brin reportedly moved to Nevada and bought a Miami home; the BBC reportedly demanded Mar-a-Lago’s membership list.
  7. Economist Gabriel Zucman explains Prop 40 as a wealth tax; Mark Ruffalo warns of health insurance losses.

And for the strike, the account firms up over the day: targets first, then the radar site and bridge, then Ozon’s denial, then casualty figures from three different sources (Zelensky, the Russian defence ministry and the city’s mayor, each with different numbers), then “over 1,000 drones, the largest attack on the city”. These are what posts claimed, not checked facts, which is why every claim keeps its source.

A visual map illustrating connections related to California's proposed billionaire wealth tax, featuring Sergey Brin's move to Nevada and a Miami purchase, alongside claims related to Mar-a-Lago and political figures like Donald Trump and Peter Thiel.

The pages play the 12 steps in order, with a speed control (2 to 10 seconds per step) and each step’s caption over the graph. They work on a phone.

5. When following goes wrong

The Nvidia story shows the weakness. Its seed names were “Nvidia”, “GPUs”, “RAM” and “Jensen Huang”, all of which people discuss constantly. So the day’s story became everything about Nvidia: warehoused GPUs, “channel stuffing” accusations, a German AI cloud deal, a new CPU. Every Nvidia post read like the story, so embeddings could not hold it together either. You can see it in the figure above: a flat, steady line rather than a wave. A story can only be followed if its names are specific to it. Steam Next Fest, a festival of hundreds of game demos, had the opposite problem: a few prolific developers filled the evidence with their own game.

A detour to Chennai: a story told in two languages

Heavy northeast monsoon rain hit Chennai on 17 and 18 October while I was recording. Bluesky had almost nothing about it (128 posts in 21 hours, mostly automatic copies of Tamil TV headlines), so I followed 1,621 news articles instead, in English and Tamil, alongside Open-Meteo’s hourly rainfall.

  • GLM cannot read Tamil. It returned nothing for seven Tamil headlines. Gemma 4 31B read them and wrote every name in English (சென்னை → Chennai, தாம்பரம் → Tambaram), so Tamil and English articles about the same place joined up in the graph.
  • News arrived the morning after. The heaviest rain fell around 00:30 IST; articles peaked six to nine hours later, and Tamil outlets were out first, from 06:30, with English coverage peaking around midday.
Bar graph showing rainfall (in mm/h) in central Chennai over time with a peak of 16 mm/h. Below, line graphs show the number of Tamil news articles, English news articles, and Bluesky posts about Chennai weather over the same period.
  • The figures firmed up through the day: 175 mm at Parrys, then “30 cm in Chennai”, then 36.34 cm overnight. The official and critical accounts sat side by side: the Corporation’s “no fatalities” and “drained by dawn, barring Perambur and Velachery”, a minister’s “Chennai cannot withstand 24 hours of continuous rain”, and a resident’s “36 cm fell in 3 hours and the weather centre gave no warning”.

One caveat: the rainfall model under reads badly. It peaked at 16 mm an hour, while the reported totals were ten times higher. It gets the timing right, not the amount.

Play the stories

Each story is an interactive page: press Play to watch it build up step by step, with a caption saying what changed, and use the slider to jump to any step. They work on a phone too. All eight are listed at https://tvganesh.github.io/bluesky-stories/.

The pages show what the models extracted, not the posts themselves: account names, links to posts and quoted posts have been removed, and posts their authors deleted after I recorded them were dropped.

This was an interesting exercise to see how the stories develop. What would be more interesting is so what cause tweets or stories to go viral. That is definitely food for thought.

Also see

To see all posts click Index of posts

Wiring a Consensus-Based Agentic Stock Picker with Anthropic and Open Models

In my previous post Building an Agentic Stock Picker with Anthropic, I implemented an agentic stock picker built with Claude Opus 5. The application analyses NSE stocks under strict governance. The rule everything follows from is simple: the market-data connection and a running AI model are never live at the same time. Data is downloaded first and frozen on the laptop; only then do the agents run, with no route back to the data provider. In the agentic stock picker Sonnet writes the fundamental and technical claims besides acting as the auditor while Haiku handles the news.

Here the claim is a single checkable assertion about one company for e.g. a sentence citing figures from the frozen data (“MCX trades at a P/E of 55.00x against a sector P/E of 32.62x, so it is expensive on earnings”), paired with the tripwire that would prove it wrong. Since, the Claude runs cost money, I also wanted to explore Open Models.

As mentioned in my earlier post, I wanted to run the agentic stock picker on Open Models hosted entirely on my own machine, with no data sent anywhere and no cost per run. I tried Qwen, GLM, GPT-OSS, Mistral, Magistral, Gemma and Nemotron, and compared every one against Claude on the same set of stocks. No single open model matched Claude, so the post ends with a consensus of three open models from different labs: Google’s Gemma, Zhipu’s GLM and Alibaba’s Qwen.

Small models exposed weaknesses that a strong model had quietly papered over, and fixing them made the whole system better, Claude runs included.

Disclaimer:Note: This is not investment advice and has been done for purely research purposes.

The setup

  • Machine: a MacBook Pro with an M3 Max chip and 64 GB of memory.
  • Model server: Ollama, which runs open models locally and serves them on the laptop’s own network interface.
  • Every model analysed the same 15 shortlisted stocks from the same frozen snapshot. The results were compared with Claude’s on that snapshot. Claude was used as the reference. I wanted to see if a local model can reach similar conclusions.

Two numbers do most of the comparing:

  • Rank agreement asks whether the model puts the 15 stocks in the same order, best to worst, as Claude. 1.0 means an identical order; 0 means no relationship.
  • Score gap asks whether the scores themselves are close: the average difference from Claude’s, as a fraction of how spread out Claude’s scores are. Lower is better.

How the agents work (a quick recap)

Each AI agent (fundamental, technical and news) looks at one company and writes claims, such as “MCX is expensive on earnings: P/E 55.00 against a sector P/E 32.62”. Every claim must carry its own falsifier/tripwire, which if evaluated to true would result in the claim being discarded. We can think of the falsifier/tripwire of the claim as the negation of the claim. For that claim, the tripwire/negation is “if P/E is below the sector’s P/E”.

Python then checks every tripwire against the frozen data:

  • If the claim’s negation/tripwire is true, the claim is proven wrong and discarded.
  • If the tripwire could never be true, the claim can’t be tested, so it’s discarded too.
  • Only claims that could have been proven wrong, but weren’t, survive and count towards the score.

An auditor model then tries to knock down the survivors, and plain arithmetic turns what’s left into a score and a BUY, WATCH or AVOID.

Ten local models were tried on the same 15 stocks, and seven were set aside:

ModelLabRank agreement with ClaudeOutcome
Gemma 4 31BGoogle0.87Member, the strongest local model
Gemma 4 26BGoogle0.74Good and faster, but from the same family as the 31B
GLM-4.7-FlashZhipu0.71Member, fast (about 25 minutes)
Qwen 3.8 27BAlibaba0.57Member; weaker alone, but the average is improved by it
MagistralMistral0.33Dropped; the average was made worse by adding it
Mistral Small 3.2Mistral0.28Dropped; TITAN was called a BUY
Nemotron 3.5 LightningNVIDIA0.05Dropped; 12 of 15 stocks were rated BUY
gpt-oss 20B, Qwen 3 8B / 14BOpenAI, Alibaba(earlier data)Dropped; too optimistic, or superseded

Claude’s run (claude-sonnet-5 with claude-haiku-4-5, about $1.92 for 15 stocks) was used as the reference throughout.

1. Tripwires pinned to the stock’s own value

Qwen 8B kept setting a tripwire’s threshold at the stock’s current value, copied from the data. TITAN’s 30-day return was −4.112031%, and the model wrote:

“The price has underperformed … by over 4 percent.” Falsifier: if return_30d_pct > -4.112031

Since the return is exactly −4.112031, the tripwire (falsifier) can never go off, so the claim can never be proven wrong. The checker correctly discards it as untestable. But 29 of the 35 pinned tripwires were on claims against a stock, so the negative claims were hit far harder than the positive ones. In the end, only 24 of 72 negative claims survived, against 70 of 92 positive ones, and every stock’s score was pushed upward.

2. Bigger model

Qwen 14B works better and does not pin negation on exact values

Qwen 14B was overly optimistic and one-sided. It wrote 95 claims in favour of stocks and 48 against, and found none of the four stocks Claude marked AVOID. On the stock MCX it praised strong returns and growth, all true, but never mentioned a P/E of 53 against a sector average of 32. This was corrected by also checking on the downside risks. After this the claims swung from 95 for and 48 against to 38 for and 64 against, with 7 AVOIDs where Claude had 4.

3. Claims written backwards

Claim: “P/E is below the sector P/E” (cheap on earnings) Tripwire: wrong if pe <= sector_pe

Here the claim and the tripwire are in the same direction which is incorrect. The tripwire is actually the correct test for the opposite claim, “expensive”. Python evaluated it faithfully: is 70.7 ≤ 6.0? Since the tripwire did not fire a false “cheap” claim survived as a point in the stock’s favour. Three such claims helped push ACUTAAS to a BUY.

4. The signal table

The fix for claims written backwards was to stop asking the model to invent rules. A python based signal table is created that defines 26 indicators with 56 rules. Each indicator has a rule for owning the stock and a rule against, and each rule comes with its exact tripwire/negation.

IndicatorFORAGAINST
P/E vs sectorbelow the sector: “cheap on earnings”above the sector: “expensive on earnings”
ROCE vs sectorabove the sectorbelow the sector
Debt trendliabilities-to-equity fallingrising
RSI-14≤ 30 oversold; 30–45 in an uptrend (a pullback); 55–70 bullish≥ 70 overbought; 30–45 in a downtrend
MACDMACD line above its signal linesignal line above the MACD line

The signal table supplied a fixed, round threshold for every rule, so a stock’s exact reading can no longer become a tripwire. Automated tests prove that every tripwire is the exact opposite of its rule, including at the edges, where mistakes like > versus >= or AND versus OR hide. The table also carries 105 worked examples from real stocks, always written figures first, conclusion after: “GMRAIRPORT trades at a P/E of 128.31x against a sector P/E of 56.75x, so it is expensive on earnings.”

Sometimes the Local LLM auditors turned out to delete true claims. Some auditors refuted by copying the claim’s own, negation minus a clause. GLM-4.7-Flash’s auditor argued:

5. Auditor changes

“ANANDRATHI’s P/E (78.50x) is higher than the sector P/E (49.79x), contradicting the claim of being expensive.”

That argument restates and confirms the claim rather than contradicting it. I added two rules:

  • A challenge that merely restates the claim can’t remove it. This is checked logically, by testing whether the challenge’s condition follows from the claim’s.
  • A comparison Python has already computed (78.50 > 49.79) can only be challenged with other evidence.

These changes were replayed on stored runs and these rules now blocked 7 of GLM’s 9 wrong vetoes, and none of Claude’s.

6. Weighting
The local LLM models still rated the shortlist well above Claude. The reasons

Valuation counted no more than a price trend.

Every model gave almost every claim 0.85–0.95 confidence, whatever the evidence. A bigger model didn’t help: Qwen 27B was exactly as confident as Qwen 14B.

So confidence for rule-based claims is now computed from the data alone for e.g. 0.55 when a indicator is just past the rule’s threshold, up to 0.90 when it’s well beyond. A P/E of 25 against a sector P/E 24 gets 0.58, similarly a P/E of 53 against sector P/E 32 gets 0.90.

Now the weight is assigned based on the group to which the indicator belongs

GroupWeightCounts for the stockCounts against the stock
Valuation1.5P/E, EV/EBITDA or P/B below the sector’sP/E, EV/EBITDA or P/B above the sector’s
Debt (leverage)1.25Liabilities-to-equity fell over the year (not banks)Liabilities-to-equity rose over the year
Returns on capital1.0ROCE, ROE or ROA above the sector’s (ROCE not for banks)ROCE, ROE or ROA below the sector’s
Growth1.0Revenue or net profit up on the previous yearRevenue or net profit down on the previous year
Margins1.0Operating margin up over the yearOperating margin down over the year
Bank quality (banks only)1.0Net interest margin above the sector, net NPA below it, or CASA above itThe reverse of any of these
Momentum1.0RSI-14 ≤ 30 (oversold); 30–45 while above the 200-day average (a pullback in an uptrend); 55–70 (bullish). MACD above its signal lineRSI-14 ≥ 70 (overbought); 30–45 while below the 200-day average. MACD below its signal line
Risk1.0Within 10% of the 52-week high; volatility below 20%; ATR below 2% of priceMore than 20% below the 52-week high; volatility above 35%; ATR above 4%
Trend0.75Above its 200-, 50- or 20-day average; MACD above zeroBelow those averages; MACD below zero
Price returns0.5Up over 30 days, 90 days or a yearDown over those periods
Volume0.5A 5-day rise on rising volumeA rise on thinning volume, or a fall on rising volume

The Stock Picker approach

The whole pipeline, as it runs today, whichever model is plugged in.

Flowchart outlining stock selection and ranking process for Nifty 500, including filtering criteria and model evaluations, with a final consensus of stock recommendations.

Stage 1: Filters (pass or fail)

Each of the roughly 500 Nifty 500 stocks must pass every filter:

FilterSetting
Return on equity (ROE)at least 12%
Return on capital employed (ROCE)at least 12% (not applied to banks)
Revenue growthnot shrinking
P/Eat most 80
RSI-14between 30 and 80
Daily price range (ATR)at most 5% of the price
Data freshnessresults under 400 days old, prices under 5 days

Missing data counts as a fail. Around 190 stocks usually pass. Debt isn’t a filter here; it’s judged later, by its trend.

Stage 2: Ranking to a shortlist of 15

The survivors are ranked on 14 measures in five groups, each measure converted to a percentile against the whole market rather than tested against a fixed cut-off:

GroupWeightMeasures
Quality30%ROCE vs sector, ROE vs sector, margin trend
Value20%P/E, P/B and EV/EBITDA vs sector (lower is better)
Growth20%revenue growth, profit growth
Trend20%price vs 200-day average, MACD, 90-day return
Risk10%ATR, volatility, distance from 52-week high

A measure a company can’t have, such as ROCE for a bank, scores at the median rather than the bottom. The top 15 go forward.

Stage 3: The AI analysts

For each of the 15, Python works out which of the 56 rules hold.

Three analysts per stock. Each of the 15 shortlisted stocks is examined by three separate analysts. Each analyst is a separate call to the AI model, and each sees only its own kind of evidence:

AnalystLooks atRules from the signal table
Fundamentalvaluation, returns on capital, growth, margins, debt, bank measures13 indicators, 26 rules
Technicalprice trend, momentum, returns, risk, volume13 indicators, 30 rules
Newsrecent headlines about the companynone; its claims are written freely

Each analyst chooses up to six claims, including both sides whenever both exist, and writes the argument. The news analyst reads the headlines separately. Python checks every claim, and the auditor challenges the survivors under the rules above.

The 56 rules. The rules are kept in a Python file called the signal table. For each of 26 indicators, one rule is given for owning the stock (FOR) and one against it (AGAINST).

A few rows of the 26 rules used by the fundamental analyst a few are shown below

IndicatorFORAGAINST
P/E vs sectorcheap on earningsexpensive on earnings
EV/EBITDA vs sectorcheap on cash profitexpensive on cash profit
P/B vs sectorcheap on book valueexpensive on book value
ROCE vs sector (not banks)returns on capital above the sectorbelow the sector
ROE vs sectorreturns to shareholders above the sectorbelow the sector

A few of the 30 rules used by the technical analyst below

IndicatorFORAGAINST
RSI-14oversold (≤ 30); pullback in an uptrend (30–45, above the 200-day average); bullish (55–70)overbought (≥ 70); weak in a downtrend (30–45, below the 200-day average)
Price vs 200-day averagelong-term uptrendlong-term downtrend
Price vs 50-day averagemedium-term uptrendmedium-term downtrend
Price vs 20-day averageshort-term strengthshort-term weakness
MACD vs its signal lineMACD bullishMACD bearish

Stage 4: Score and recommendation

Each surviving claim contributes:

± dimension weight × group weight × confidence

Fundamental claims weigh 1.0, technical 0.8 and news 0.5; the group weights are in the table above.

For example, a stock clearly expensive on P/E (53 against 32, confidence 0.90) loses 1.0 × 1.5 × 0.90 = −1.35. Clearly strong ROCE earns 1.0 × 1.0 × 0.90 = +0.90. A clear 200-day uptrend earns 0.8 × 0.75 × 0.90 = +0.54. One clear valuation concern outweighs a strong return on capital.

The recommendation rules:

LabelRule
BUYscore ≥ 1.0, and positive claims in at least two dimensions, and no surviving claim against the business at confidence 0.6 or more
AVOIDscore ≤ −0.5
WATCHeverything else

A single clear negative about the business blocks a BUY, and most high-quality companies are expensive, so BUY is rare by design.

The results for the seven open models against Claude

ModelMakerRank vs ClaudeScore gapBUY / WATCH / AVOIDRun time
Claude (API, about $1.90 per run)Anthropic——1 / 9 / 514 min
Gemma 4 31BGoogle0.870.451 / 13 / 1~1.5 h
Gemma 4 26BGoogle0.740.461 / 12 / 2~35 min
GLM-4.7-FlashZhipu0.710.693 / 11 / 127 min
Qwen 3.8 27BAlibaba0.570.692 / 11 / 21 h 35 min
MagistralMistral0.330.872 / 12 / 11 h 3 min
Mistral Small 3.2Mistral0.280.762 / 13 / 01 h 18 min
Nemotron 3.5 LightningNVIDIA0.051.5012 / 3 / 020 min

gpt-oss 20B (OpenAI’s open-weight model) was tested on the earlier data only. It tended to be too optimistic, with rank agreement 0.31, and wasn’t carried it forward. Neither did the Qwen 3 8B and 14B models, which the table above already covers.

  • Google’s Gemma 4 31B agreed with Claude best by a clear margin, and wrote no invalid claims at all. It had been released only days before these runs.
  • Some models are simply optimistic. Nemotron rated 12 of the 15 a BUY, including TITAN, the stock Claude rated worst. gpt-oss and Mistral leaned the same way to a lesser degree. What set them apart was a tendency to choose supporting facts over warning signs, even though every fact they were given was true.
  • Speed and quality are not the same axis. The fastest models (Nemotron, gpt-oss) were among the least aligned, while GLM-4.7-Flash and Gemma 4 26B were both fast and reasonably close.
  • Every model agreed on one stock. GESHIP (Great Eastern Shipping) was a BUY for Claude and every open model tested on this data. It had a P/E of 5.7 against a sector 15, returns well above its peers, falling debt and an upward trend.
  • Several models agreed on the other end. TITAN was an AVOID for Claude, GLM, Qwen 27B and Gemma 4 26B. Its business is excellent (ROE 32%, revenue up 45%), but at 27.6 times book value against a sector 3.3, with rising debt and fading momentum, the valuation and debt weights pull it down.

A consensus of models

Because no single open model matches Claude, I tried averaging several. Models from different labs tend to make different mistakes:

CombinationRank vs ClaudeScore gap
GLM + Qwen 27B0.800.57
GLM + Qwen 27B + Gemma 4 31B0.820.52

The GLM and Qwen pair beat both of its members, and adding Gemma 4 31B improved it further. The trio still falls short of Gemma 4 31B on its own (0.87), which argues for giving Gemma a strong voice. So a three-lab consensus (Zhipu, Alibaba and Google) is taken to decide which stock gets a BUY or AVOID only when most models agree. The two Gemma models together scored higher still (0.92), but coming from the same family, they’re more likely to share blind spots.

Flowchart illustrating a consensus process for evaluating different AI models, including Gemma 4, GLM-4.7-Flash, Qwen 3.8, and Claude, showcasing the steps of scoring and labeling per stock and the final consensus calculation.

Here is what we get when we run each LLM independently

StockClaudeGLM-4.7-FlashQwen 3.8 27BGemma 4 26BGemma 4 31B
IKSWATCH (+4.95)WATCH (+3.34)WATCH (+4.65)WATCH (+2.85)WATCH (+4.65)
GESHIPBUY (+4.48)BUY (+4.66)BUY (+4.19)BUY (+5.00)BUY (+5.79)
ENGINERSINWATCH (+2.63)WATCH (+3.25)WATCH (+1.06)WATCH (+0.36)WATCH (+3.00)
LODHAWATCH (+1.84)BUY (+2.44)WATCH (+2.21)WATCH (+1.03)WATCH (+1.85)
HINDZINCWATCH (+1.83)WATCH (+1.59)WATCH (+3.75)WATCH (+1.67)WATCH (+2.50)
GABRIELWATCH (+1.56)WATCH (+2.96)WATCH (+0.28)WATCH (+0.66)WATCH (+1.56)
HBLENGINEWATCH (+0.74)WATCH (+2.49)WATCH (0.00)WATCH (+0.96)WATCH (+0.63)
MCXWATCH (+0.36)WATCH (+0.88)WATCH (+1.18)WATCH (−0.17)WATCH (+0.88)
LLOYDSMEWATCH (+0.30)WATCH (+0.49)WATCH (+1.76)WATCH (−0.13)WATCH (+1.57)
LUPINWATCH (−0.49)WATCH (+1.51)BUY (+2.90)WATCH (+0.07)WATCH (+1.27)
PREMIERENEAVOID (−0.55)WATCH (+1.94)WATCH (+0.98)WATCH (+1.87)WATCH (+0.73)
MUTHOOTFINAVOID (−0.91)BUY (+3.25)AVOID (−1.51)WATCH (+0.23)WATCH (−0.13)
KALYANKJILAVOID (−1.07)WATCH (+1.57)WATCH (+2.28)AVOID (−1.15)WATCH (+1.09)
GLENMARKAVOID (−1.39)WATCH (−0.01)WATCH (+0.30)WATCH (−0.10)AVOID (−0.89)
TITANAVOID (−2.28)AVOID (−0.92)AVOID (−0.75)AVOID (−1.85)WATCH (+0.40)
BUY / WATCH / AVOID1 / 9 / 53 / 11 / 12 / 11 / 21 / 12 / 21 / 13 / 1
Rank agreement with Claude—0.710.570.740.87
Score gap vs Claude—0.690.690.460.45

The number in brackets is each stock’s score. A stock is BUY at a score of 1.0 or more, but only if it is positive in at least two of the fundamental, technical and news areas and has no strong negative business claim. It is AVOID at −0.5 or below, and WATCH otherwise.

The Consensus strategy

The whole pipeline is run separately by each AI model, and a score and a BUY, WATCH or AVOID are produced for every stock . The finished results are then combined by regular Python.

  • The order of the stocks is set by a weighted average of the models’ scores.
  • The label is decided by a weighted vote on the models’ own labels. A stock is made a BUY only if BUY was given by models holding at least half the weight, and an AVOID if AVOID was given by models holding at least half.
  • A BUY is blocked by a heavyweight objection. If AVOID is given by any model holding 0.3 or more of the weight, the stock cannot be made a consensus BUY.
  • Everything else is labelled WATCH.
ModelWeight of its vote, without ClaudeWeight of its vote, with Claude
Claudenot in the vote0.40
Gemma 4 31B0.40.25
GLM-4.7-Flash0.30.175
Qwen 3.8 27B0.30.175
Total1.01.00
  1. Order: the weighted average of the scores

weighted score = Σ (model weight × model’s score)

With Claude, for GESHIP: 0.40 × 4.48 + 0.25 × 5.79 + 0.175 × 4.66 + 0.175 × 4.19 = +4.79.

2. . The BUY test

A stock is made a consensus BUY only when all three checks are passed:

  • No veto: AVOID was not voted by any single model whose own weight is 0.3 or more (rule 3).
  • Majority: the BUY weight is 0.5 or more.
  • Score: the weighted score is +1.0 or more.

3. The veto: a BUY is blocked by a heavyweight objection

If AVOID was voted by any one model whose weight is 0.3 or more, the stock cannot be made a BUY, however large the BUY weight.

Who can cast a veto is therefore set by the weights:

VersionWeight of 0.3 or moreCan veto a BUY on its own
Without ClaudeGemma (0.4), GLM (0.3), Qwen (0.3)all three
With ClaudeClaude (0.40) onlyClaude only; Gemma (0.25), GLM and Qwen (0.175) can vote against a BUY, but cannot block one alone

4. The AVOID test

A stock that is not a BUY is made an AVOID when either check is passed:

  1. Majority: the AVOID weight is 0.5 or more, or
  2. Score: the weighted score is −0.5 or below.

Unlike BUY, AVOID can also be reached through the score alone

5. Everything else is WATCH

This includes an exact even split, where the BUY weight and the AVOID weight are both 0.5. When the models are divided, “watch’ should be fine.

Using weights (without Claude)**

Gemma (0.4)GLM (0.3)Qwen (0.3)BUY weightAVOID weightVeto?ResultExample
BUYBUYBUY1.00noBUYGESHIP
BUYBUYWATCH0.4 + 0.3 = 0.70noBUYillustration
WATCHBUYWATCH0.30noWATCH: 0.3 is short of 0.5LODHA
BUYBUYAVOID0.70.3yes: Qwen’s 0.3WATCHillustration
WATCHAVOIDAVOID00.3 + 0.3 = 0.6—AVOIDTITAN
WATCHBUYAVOID0.30.3—WATCH: no majority either wayMUTHOOTFIN
AVOIDWATCHWATCH00.4—WATCH: 0.4 is short of 0.5, score −0.27GLENMARK
WATCHWATCHWATCH00—WATCH, despite a score of +4.26IKS

The results on 28 Sep data (with prices to 25 Sep 2026)

A) Without Claude (Gemma 0.4, GLM 0.3, Qwen 0.3)**

StockGemma 4 31BGLM-4.7Qwen 27BWeighted scoreConsensusClaude (not voting)
GESHIPBUY (+5.79)BUY (+4.66)BUY (+4.19)+4.97BUYBUY
IKSWATCH (+4.65)WATCH (+3.34)WATCH (+4.65)+4.26WATCHWATCH
HINDZINCWATCH (+2.50)WATCH (+1.59)WATCH (+3.75)+2.60WATCHWATCH
ENGINERSINWATCH (+3.00)WATCH (+3.25)WATCH (+1.06)+2.49WATCHWATCH
LODHAWATCH (+1.85)BUY (+2.44)WATCH (+2.21)+2.14WATCHWATCH
LUPINWATCH (+1.27)WATCH (+1.51)BUY (+2.90)+1.83WATCHWATCH
GABRIELWATCH (+1.56)WATCH (+2.96)WATCH (+0.28)+1.60WATCHWATCH
KALYANKJILWATCH (+1.09)WATCH (+1.57)WATCH (+2.28)+1.59WATCHAVOID
LLOYDSMEWATCH (+1.57)WATCH (+0.49)WATCH (+1.76)+1.30WATCHWATCH
PREMIERENEWATCH (+0.73)WATCH (+1.94)WATCH (+0.98)+1.17WATCHAVOID
HBLENGINEWATCH (+0.63)WATCH (+2.49)WATCH (0.00)+1.00WATCHWATCH
MCXWATCH (+0.88)WATCH (+0.88)WATCH (+1.18)+0.97WATCHWATCH
MUTHOOTFINWATCH (−0.13)BUY (+3.25)AVOID (−1.51)+0.47WATCHAVOID
GLENMARKAVOID (−0.89)WATCH (−0.01)WATCH (+0.30)−0.27WATCHAVOID
TITANWATCH (+0.40)AVOID (−0.92)AVOID (−0.75)−0.35AVOIDAVOID

B) With Claude (Claude 0.40, Gemma 0.25, GLM 0.175, Qwen 0.175)**

StockClaudeGemma 4 31BGLM-4.7Qwen 27BWeighted scoreConsensus
GESHIPBUY (+4.48)BUY (+5.79)BUY (+4.66)BUY (+4.19)+4.79BUY
IKSWATCH (+4.95)WATCH (+4.65)WATCH (+3.34)WATCH (+4.65)+4.54WATCH
ENGINERSINWATCH (+2.63)WATCH (+3.00)WATCH (+3.25)WATCH (+1.06)+2.56WATCH
HINDZINCWATCH (+1.83)WATCH (+2.50)WATCH (+1.59)WATCH (+3.75)+2.29WATCH
LODHAWATCH (+1.84)WATCH (+1.85)BUY (+2.44)WATCH (+2.21)+2.02WATCH
GABRIELWATCH (+1.56)WATCH (+1.56)WATCH (+2.96)WATCH (+0.28)+1.58WATCH
LLOYDSMEWATCH (+0.30)WATCH (+1.57)WATCH (+0.49)WATCH (+1.76)+0.91WATCH
LUPINWATCH (−0.49)WATCH (+1.27)WATCH (+1.51)BUY (+2.90)+0.90WATCH
HBLENGINEWATCH (+0.74)WATCH (+0.63)WATCH (+2.49)WATCH (0.00)+0.89WATCH
MCXWATCH (+0.36)WATCH (+0.88)WATCH (+0.88)WATCH (+1.18)+0.73WATCH
KALYANKJILAVOID (−1.07)WATCH (+1.09)WATCH (+1.57)WATCH (+2.28)+0.52WATCH
PREMIERENEAVOID (−0.55)WATCH (+0.73)WATCH (+1.94)WATCH (+0.98)+0.47WATCH
MUTHOOTFINAVOID (−0.91)WATCH (−0.13)BUY (+3.25)AVOID (−1.51)−0.09AVOID
GLENMARKAVOID (−1.39)AVOID (−0.89)WATCH (−0.01)WATCH (+0.30)−0.73AVOID
TITANAVOID (−2.28)WATCH (+0.40)AVOID (−0.92)AVOID (−0.75)−1.11AVOID

**Please note

Disclaimer:The stocks listed here should not be used as investment advice. This has been done for research purposes.

Some findings

  • The clear calls are agreed by both. GESHIP is rated BUY and TITAN AVOID in both versions.
  • More caution is shown by the version without Claude. Where the models disagree, WATCH is assigned.
  • The weak end is mostly sharpened by adding Claude.
  • Only 15 stocks on one date have been tested. The data is at least 2 weeks old
  • This has been done for research purposes alone and is not a stock recommendation

See also

  1. Building a Flight Simulator with Claude Code, Opus 5
  2. Introducing IPL AI Oracle: AI that speaks cricket!!!
  3. Introducing QCSimulator: A 5-qubit quantum computing simulator in R
  4. Experiments with deblurring using OpenCV
  5. Singularity

To see all posts click Index of Posts