newsscore
News sentiment scoring for stocks, powered by TypeSafe Jev by default. Mainstream financial news APIs go in, a pluggable
scoring function (TypeSafe's Jev by default) scores every article, and one
aggregate number per symbol comes out. Use it from Python (sync or async) or from the
newsscore command line.
sources ──► fetch (async, concurrent) ──► de-dupe ──► score_fn (batched, cached) ──► aggregate ──► ScoreResult
Install
From PyPI:
pip install "newsscore[jev]" # with the Jev scorer (recommended)
pip install newsscore # without it; keyword scorer or your own score_fn
Or with uv: uv add "newsscore[jev]", or uv tool install "newsscore[jev]" for the CLI only.
The latest unreleased code is on GitHub, and a clone is the way to hack on it:
pip install "newsscore[jev] @ git+https://github.com/mahynotch/newsscore.git" # main branch
git clone https://github.com/mahynotch/newsscore.git && cd newsscore
pip install -e ".[jev,dev]" # editable, with the Jev SDK and the test tools
Python 3.10+. Runtime dependencies: httpx, typer, platformdirs.
Two ways to reach Jev
newsscore can call Jev either directly or through OpenRouter. The questions asked are
identical -- one definition renders into both wire formats -- so the only differences
are the credential, the dependency and who bills you.
provider="typesafe" |
provider="openrouter" |
|
|---|---|---|
| credential | TYPESAFE_API_KEY |
OPENROUTER_API_KEY |
needs [jev] extra |
yes | no, just httpx |
| model id | jev-1.13.0 |
typesafe/jev-1.13 |
| endpoint | POST /v1/systemone |
POST /api/alpha/decisions |
| sign-up | waiting list | open |
newsscore score AAPL --scorer jev --provider openrouter
NewsScorer("jev", scorer_options={"provider": "openrouter"})
Leave it unset and whichever key you have is used, preferring TypeSafe, so an existing
setup keeps working untouched. newsscore doctor reports both routes.
Do the two routes agree?
Measured, rather than assumed. The same 310 AAPL articles were scored down both routes:
| TypeSafe vs OpenRouter | same route, scored twice | |
|---|---|---|
| impact label agreement | 97.1% (kappa 0.934) | 96.7% (kappa 0.92) |
| category agreement | 97.7% | 99.3% |
| sentiment, max abs. difference | 0.095 | 0.095 |
| tokens per article | 1241 | 1241 |
| aggregate over all 310 | +0.1160 | +0.1170 |
The gap between providers is no bigger than the model's own run-to-run variation,
so the benchmark in Is impact worth asking for? carries
over. That is evidence, not a guarantee: OpenRouter resolves typesafe/jev-1.13 to a
dated build (typesafe/jev-1.13-20260917, visible in labels["model"]), and it is
free to resolve it elsewhere later. Pin the dated id yourself if that matters --
OpenRouter accepts it directly.
The provider is therefore part of the cache fingerprint, and scores fetched through one route are never reused for the other. Switching providers means rescoring.
OpenRouter spells its floating aliases with a leading ~, so the moving pointer is
~typesafe/jev-latest and the pinned versions are typesafe/jev-1.13 and the dated
typesafe/jev-1.13-20260917. As on TypeSafe, asking for an alias switches caching
off rather than filing answers under a name that may mean something else tomorrow:
JevScorer(provider="openrouter", model="~typesafe/jev-latest").fingerprint is None
One more caveat: OpenRouter's Decisions endpoint is alpha, so its shape can change without a deprecation period.
Do I need the [jev] extra?
Only for the default scorer. [jev] adds one package, typesafe-sdk, and JevScorer is
the only thing that imports it — sources, aggregation, caching, the CLI and your own
score_fn all work without it.
| you want | install | also needed |
|---|---|---|
| Jev scoring (the point of this package) | [jev] |
TYPESAFE_API_KEY |
your own model as score_fn |
plain | nothing |
| a look at the plumbing, offline | plain | nothing — you get the keyword scorer |
NewsScorer() uses Jev when the SDK is importable and TYPESAFE_API_KEY is set, and
otherwise falls back to the keyword lexicon scorer with only a log line. That fallback is
a test double, not a trading signal, so run newsscore doctor to see which one you have
before trusting a number.
API keys: the .env file
Copy .env.example to .env in the project (or working) directory and fill in the
keys you have:
TYPESAFE_API_KEY=... # Jev scorer
FINNHUB_API_KEY=...
POLYGON_API_KEY=...
newsscore and NewsScorer.from_config() load it automatically. Real environment
variables always win, blank values are ignored, and the file is git-ignored. Lookup:
$NEWSSCORE_ENV alone if set, otherwise ./.env, then .env in the user config directory. In code you
can also call load_env("path/to/.env") yourself before constructing sources.
Quick start: CLI
# 1. save some sources (stored in a per-user JSON file, see `newsscore config-path`)
newsscore source add yahoo # keyless RSS, works immediately
newsscore source add finnhub --api-key YOUR_KEY
newsscore source add polygon # key taken from $POLYGON_API_KEY
# 2. score
newsscore score AAPL # all saved sources, last 7 days
newsscore score AAPL -s finnhub -s yahoo -d 3 # only these sources, last 3 days
newsscore score AAPL --scorer keyword -a 10 # force the offline scorer, show 10 articles
newsscore score AAPL --json | jq .score
# 3. look around
newsscore fetch AAPL # articles only, no scoring
newsscore fetch AAPL --out news.json # ...saved as JSON instead of printed
newsscore score AAPL --out aapl.json # full result, every scored article
newsscore source list | types | remove NAME
newsscore doctor # shows every path in use
Sample output (real run, four free-tier sources, Jev scorer):
query AAPL
window 2026-09-10 .. 2026-09-17 (7 days)
scorer jev-v1
articles 310
status ok
counts fetched=326 deduplicated=16 filtered=0 submitted=310 scored=310 failed=0
score +0.120 (-1 bearish .. +1 bullish)
confidence 1.000
by source finnhub=+0.10 newsapi=+0.25 polygon=+0.28 yahoo=+0.10
+0.50 c=0.98 r=0.97 09-17 21:45 [yahoo] Apple (AAPL) Outperforms Broader Market: What You Need to Know
-0.01 c=0.99 r=0.02 09-17 20:20 [yahoo] Should You Buy HP Stock For The Shares It Keeps Retiring?
-0.98 c=0.97 r=0.97 09-17 16:44 [yahoo] Apple (AAPL) Faces a $2.7 Billion UK Lawsuit over its App Tracking Rules
c is Jev's confidence and r its relevance; the HP article is correctly weighted
out. 310 articles took about 15 s to score the first time and under 2 s from cache.
Quick start: Python
from newsscore import NewsScorer
scorer = NewsScorer() # Jev if TYPESAFE_API_KEY is set, else keyword scorer
scorer.source_add("finnhub", api_key="...")
scorer.source_add("yahoo") # keyless
scorer.source_add("rss", name="ft", url="https://www.ft.com/companies?format=rss")
result = scorer.score("AAPL", days=7) # blocking
print(result.score, result.confidence, result.n_articles)
for item in result.articles[:5]:
print(item.score.score, item.article.title)
Inside an async pipeline use the a-prefixed methods; everything network-bound is
async and sources are fetched concurrently:
import asyncio
from newsscore import NewsScorer
async def main():
scorer = NewsScorer.from_config() # loads the sources saved by the CLI
results = await asyncio.gather(*(scorer.ascore(s, days=3) for s in ["AAPL", "MSFT", "NVDA"]))
for r in results:
print(r.query, f"{r.score:+.3f}", f"conf={r.confidence:.2f}", r.n_articles)
await scorer.aclose()
asyncio.run(main())
Pass your own httpx.AsyncClient with NewsScorer(http_client=client) to share
connection pools with the rest of your pipeline. result.to_dict() gives plain JSON.
Bring your own scoring function
The scorer is just a callable. Pass it as score_fn:
from newsscore import NewsScorer, Article, ArticleScore, per_article
# batch form (preferred): one call per batch of up to `batch_size` articles
async def my_scorer(articles: list[Article], query: str) -> list[ArticleScore]:
texts = [a.text for a in articles]
probs = await my_model.predict(texts) # your code
return [ArticleScore(score=2 * p - 1, confidence=abs(2 * p - 1)) for p in probs]
my_scorer.name = "my-model-v3" # stable cache key
scorer = NewsScorer(score_fn=my_scorer, batch_size=32, concurrency=2)
# or one article at a time
scorer = NewsScorer(score_fn=per_article(lambda a, q: 0.5 if "beat" in a.text.lower() else 0.0),
scorer_name="beat-rule")
Contract
| Signature | fn(articles: Sequence[Article], query: str), plain or async |
| Input | articles: the batch (see batch_size, default 16). query: the symbol or keyword they were fetched for. Each Article has id, source, title, published (UTC), url, summary, symbols, raw (vendor payload) and text (title + summary). |
| Output | A sequence with one item per input article, same order. Each item is an ArticleScore, or a number in [-1, 1], or a dict {"score": float, "confidence"?: float, "relevance"?: float, "labels"?: dict}, or a ScoreItemError to fail that one article. |
score |
-1.0 very bearish .. +1.0 very bullish. |
confidence |
[0, 1], how sure the scorer is. Aggregation weight. Default 1.0. |
relevance |
[0, 1], how much the article is about query. Aggregation weight. Default 1.0. |
labels |
Anything you want to keep (category, probabilities, model version). Stored in the cache. |
| Errors | Return a ScoreItemError to fail one article and keep its siblings. Raising fails the batch, which the engine then retries and splits to isolate the bad article. Either way the failure lands in ScoreResult.failures and never enters the aggregate as neutral. |
| Caching | Results are cached under (scorer name, query, article id). Set fn.name or NewsScorer(scorer_name=...); anonymous lambdas are not cached. |
The full contract also lives in the docstring of newsscore/scoring/protocol.py.
Built-in scorers
| name | needs | what it does |
|---|---|---|
jev (default when available) |
pip install ".[jev]", TYPESAFE_API_KEY |
One Jev system_one call per article asking a 5-level sentiment Score, a relevance Noul, an event Choice (earnings, guidance, M&A, legal, product, analyst, management, macro, other) and a novelty Noul. Score is the probability-weighted level rescaled to [-1, 1]; confidence is Jev's calibrated confidence. |
keyword |
nothing | Small Loughran-McDonald-style lexicon with negation handling. Offline fallback and test double, not a trading signal. |
Select explicitly with NewsScorer(score_fn="keyword") or newsscore score --scorer keyword.
Tune Jev with NewsScorer(score_fn=JevScorer(concurrency=16, model="jev-latest")).
News sources
| type | key env var | query | notes |
|---|---|---|---|
finnhub |
FINNHUB_API_KEY |
ticker | company-news endpoint |
alpha_vantage |
ALPHAVANTAGE_API_KEY |
ticker | vendor sentiment kept in Article.raw |
polygon / massive |
POLYGON_API_KEY or MASSIVE_API_KEY |
ticker | Polygon.io is now Massive.com; same keys. Follows next_url; option max_pages (5) |
tiingo |
TIINGO_API_KEY |
ticker | option limit (1000) |
marketaux |
MARKETAUX_API_KEY |
ticker | option max_pages (3), language |
newsapi |
NEWSAPI_API_KEY |
keyword | pass a company name; option max_pages (1), language |
rss |
none | keyword | option url (use {query} as placeholder), match (true) |
yahoo |
none | ticker | Yahoo Finance headline RSS preset |
Add a source by type and options:
newsscore source add marketaux --api-key KEY -o max_pages=5
newsscore source add rss --name sec -o url="https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&output=atom"
or in code: scorer.source_add("marketaux", api_key="KEY", max_pages=5).
Writing a source
from newsscore import NewsSource, register
@register
class MySource(NewsSource):
type_name = "mine"
env_key = "MINE_API_KEY" # or requires_key = False
async def fetch(self, query, since, until, client):
data = await self._get_json(client, "https://...", params={"q": query, "key": self.api_key})
return [self._article(title=d["title"], published=parse_dt(d["ts"]), url=d["url"]) for d in data]
since/until are aware UTC datetimes; client is a shared httpx.AsyncClient.
Registered types are available to the CLI too.
How the aggregate is computed
weight_i = confidence_i * relevance_i * impact_i * 0.5 ** (age_hours_i / half_life_hours) # half_life 48h
score = sum(weight_i * score_i) / sum(weight_i)
confidence = 1 - exp(-sum(weight_i) / 3) # ~0.63 with three solid fresh articles
impact_i is 1.0 unless you switch on impact weighting, so by default this is
exactly the confidence/relevance/decay scheme and nothing else.
Every term is configurable, and nothing about the defaults changed when these knobs were added:
| control | default | what it does |
|---|---|---|
half_life_hours |
48 |
decay half-life; 0 disables decay |
lookback_hours |
None |
ignore articles older than this when aggregating |
use_relevance |
True |
set False to drop the relevance term |
impact_weights |
None |
see Expected impact |
aggregate_fn |
built-in | replace the scheme entirely |
lookback_hours bounds what is aggregated; days/since bound what is fetched.
They are separate on purpose: you can collect a week of news and still aggregate only
the last 48 hours of it without re-fetching.
The reference time is explicit everywhere — until for score, as_of for
score_articles and aggregate_scored — so a run replays deterministically. Articles
published after it are excluded rather than counted as the freshest evidence available.
Auditing an aggregate
Every run reports, per article, each weight term and a normalised contribution.
The contributions sum to the reported score, so any aggregate can be traced to the
articles and the term that moved it:
score conf rel impact decay weight contrib headline
+0.99 0.99 0.97 1.0 0.973 0.934 +0.3157 Apple Stock Gets Stunning Price Target Hike
+0.47 0.94 0.95 1.0 0.969 0.866 +0.1367 Apple Stock Near Buy Point As iPhone 18 ...
+0.49 0.87 0.60 1.0 0.976 0.509 +0.0857 GM is re-downloading Apple CarPlay ...
+0.42 0.58 0.95 1.0 0.970 0.534 +0.0753 Apple will essentially sell 'every single'...
+0.29 0.65 0.16 1.0 0.967 0.101 +0.0101 Sector Update: Tech Stocks Rise Late ...
sum = +0.6235
result.total_weight # 2.944
result.reconciles() # True
[c.to_dict() for c in result.contributions]
reconciles() is also true, vacuously, for a custom aggregate_fn: contributions
summing to the score is a property of a weighted mean, not of aggregation in general,
so your own function is never asked to provide them and reports none.
A zero total_weight is reported as status="no_weight", which is deliberately not
the same as a genuine 0.0 — see When a run goes wrong.
Reproducing another aggregator
Comparing schemes needs no model calls at all. quant_aggregator() is the scheme used
by the existing quant pipeline — confidence x impact x decay, a 12-hour half-life, a
48-hour lookback and no relevance term — so it can be reproduced exactly without this
library changing its own general-purpose defaults:
from newsscore import NewsScorer, quant_aggregator
saved = json.loads(Path("aapl.json").read_text())
theirs = NewsScorer(aggregate_fn=quant_aggregator()).aggregate_scored(
saved["articles"], saved["query"], as_of=saved["until"]
) # +0.6034 against the default's +0.6235
From the command line, --half-life, --lookback, --no-relevance and
--impact-weighting cover the same ground.
Scoring news you already have
If your pipeline already collects articles, skip the fetching entirely and use the scoring and aggregation on their own. Same scorer, same cache, same aggregator, so results are directly comparable with the fetching path.
from newsscore import NewsScorer
scorer = NewsScorer() # no sources needed
result = scorer.score_articles(my_articles, "AAPL", as_of=cutoff)
for item in result.articles:
print(item.article.id, item.score.score, item.score.confidence)
my_articles are Article objects or plain dicts shaped like Article.to_dict().
Only title and published are required; id, source, url, summary and
symbols are carried through exactly as given and never regenerated, so the
outcome for each article binds to your own id. Use ascore_articles in async code.
as_ofis the reference time for decay and the eligibility cutoff. Articles published after it are counted asfiltered, not treated as fresh evidence. Fix it to replay a run deterministically.- One article, two targets is two tasks.
score_articles(arts, "AAPL")andscore_articles(arts, "MSFT")are scored, cached and reported independently — relevance alone can differ enough to matter. - Every input is accounted for in
result.counts, which is whyfilteredanddeduplicatedare reported separately. Passdedupe=Falseto keep syndicated copies.
From the command line
newsscore score-articles news.json -q AAPL # a file
cat news.json | newsscore score-articles -q AAPL --json # or stdin
newsscore fetch AAPL --out news.json # this output is valid input
The input is either a JSON array of articles or an object carrying the target with them:
{"query": "AAPL", "as_of": "2026-09-18T12:00:00Z", "articles": [
{"id": "a1", "source": "internal", "title": "Apple beats estimates",
"published": "2026-09-17T14:00:00Z", "url": "https://...", "symbols": ["AAPL"]}
]}
--scorer, --half-life, --no-cache, --articles, --json, --out and --fail-on
work exactly as they do for newsscore score.
Re-aggregating without paying again
Changing how scores are combined needs no model calls at all. Hand the scored articles back with different settings:
import json
saved = json.loads(Path("aapl.json").read_text())
NewsScorer(half_life_hours=12).aggregate_scored(saved["articles"], saved["query"],
as_of=saved["until"])
aggregate_scored is synchronous, never touches the network or the scorer, and returns
a full ScoreResult. It is the cheap way to sweep a half-life over a saved run, or to
re-score history after changing only the aggregation.
Expected impact
Jev answers a second, separate question about every article: how material is this
news for the target, over the next 1–5 trading days? That lands on ArticleScore as
expected_impact — "low", "medium", "high", or None from a scorer that does
not judge impact — with the full distribution in labels["impact_probs"].
Impact is not sentiment. Sentiment is direction and strength; impact is how much the news should move your view at all. A lawsuit is strongly bearish and high impact; a routine supplier contract is mildly bullish and low impact. The rubric says this explicitly, because a model asked casually will otherwise just read impact off the strength of the sentiment.
for item in result.articles:
s = item.score
print(f"{s.score:+.2f} {s.expected_impact or '-':>6} {item.article.title}")
The question is off by default, because it costs about 24% more tokens per article and the label does nothing unless you also switch on impact weighting. Ask for it when you intend to use it:
NewsScorer("jev", scorer_options={"impact": True}) # ask for it: ~24% more tokens
JevScorer(impact=True, horizon="the next trading day") # ask about a different period
JevScorer(impact=True, impact_rubric={...}) # reword the three levels
or --impact on the command line. With the question off, expected_impact is None
everywhere and nothing else changes -- see Is impact worth
asking for? for the evidence.
The rubric keys stay low/medium/high -- only their descriptions are yours, since
ArticleScore.expected_impact and the weight mapping are defined on those three. The
horizon, the rubric wording and whether the question is asked at all are all part of
the cache fingerprint, so changing any of them rescores rather than reusing answers
given to a different question.
Weighting by impact
Off by default. Pass a mapping to switch it on, in code or with --impact-weighting:
from newsscore import NewsScorer, IMPACT_WEIGHTS # {"high": 3.0, "medium": 1.5, "low": 1.0}
scorer = NewsScorer(impact_weights=IMPACT_WEIGHTS) # or your own mapping
Articles whose scorer supplies no impact weigh 1.0, so enabling this changes only the
articles that actually carry a judgement, and the keyword scorer or your own function
keeps working untouched.
Is impact worth asking for?
Short answer: the label is real, but nothing shows it makes your number better. It is off by default for that reason. The measurements below are from this repository's benchmark against the live Jev API on real news.
It is not a restatement of sentiment. On 308 live AAPL articles, the best possible
two-threshold rule on |sentiment| reproduces the impact label 71.8% of the time
against a 70.1% majority-class baseline -- a 1.7pp gain, i.e. none. Normalised mutual
information between |sentiment| and impact is 0.215, and AUC for ranking high
against the rest is 0.70. Category and relevance each explain about as much as
sentiment does. So the question really is asking something else.
It is reproducible. Scoring the same 150 articles twice with identical settings changed the label 3.3% of the time, Cohen's kappa 0.92. The signal is not sampling noise.
Asking it does not disturb the other answers. Differences in sentiment, confidence and relevance between the impact-on and impact-off arms are indistinguishable from the run-to-run noise floor (max |delta| 0.100 against 0.095 measured on identical repeats, median zero, no directional bias).
Most articles are low. 70.3% low, 24.5% medium, 5.2% high. Under IMPACT_WEIGHTS
only 30% of articles get a weight other than 1.0.
It moves the answer a little, and sometimes flips it.
| basket size | median abs. change | p90 | sign flips |
|---|---|---|---|
| 5 articles | 0.020 | 0.094 | 4.2% |
| 10 articles | 0.033 | 0.111 | 7.2% |
| 20 articles | 0.028 | 0.086 | 6.8% |
| 40 articles | 0.021 | 0.068 | 8.5% |
But it is not validated against what actually happened. On 597 articles across 20 symbols, using split- and dividend-adjusted daily closes, and measuring each article's move as a residual against SPY scaled by the symbol's own residual volatility:
| low | medium | high | high - low | p | |
|---|---|---|---|---|---|
| news day + next | 0.680 | 0.645 | 0.740 | +0.060 | 0.219 |
| 5 days forward | 0.490 | 0.617 | 0.677 | +0.187 | 0.085 |
Residualising matters here: on raw returns every one of these differences washes out, because three weeks of megacap moves are mostly market beta.
The five-day ordering is monotone and in the right direction, which is what a weak but
real signal looks like; it is not significant at conventional levels, and the one-day
test is flat. Switching impact weighting on did not improve how well the daily
aggregate tracked the forward residual return (Spearman -0.036 to -0.032 -- both
indistinguishable from zero, in a window where plain sentiment had no forward power
either). With only 18 high-impact articles in the sample this study is underpowered:
settling it at conventional significance would take on the order of 15,000 scored
articles, given a ~3-5% base rate for high.
So: a stable, non-redundant label, of unproven value, that costs 24% more and changes your number by about 0.03. Turn it on if you plan to validate it on your own universe.
Before you put a 3× weight on high, label a few hundred headlines yourself and check
the labels agree with you. See the caveat at the end of this file.
What a cached score belongs to
A score is reused only when it would be identical to recompute. The cache key covers the scorer, its contract fingerprint, the target, the article id and a digest of the article's text:
| change | old scores reused? |
|---|---|
| same everything | yes — served locally, no request |
| headline edited under the same url | no — content is part of the identity |
| different target symbol | no — a separate scoring task |
| rubric, horizon or question wording changed | no — the model was asked something else |
| model version changed | no |
The last three are what fingerprint buys you. A scorer publishes one as
fn.fingerprint; set it to a digest of whatever changes your answers, or leave the
attribute off to cache on the scorer name alone as before.
The model is pinned
JevScorer requests a pinned version (jev-1.13.0) rather than jev-latest. This is
deliberate: the API resolves an alias server-side and only tells you which version
answered after the call, so an alias can never be part of a cache lookup — a silent
model swap would keep serving the old model's scores forever. Ask for an alias anyway
and the scorer refuses to cache rather than store answers it cannot attribute:
JevScorer(model="jev-latest").fingerprint is None # -> caching disabled, with a warning
Expect scores to move when the pinned default is raised; the cache invalidates wholesale at that point, which is the correct behaviour rather than a bug.
Provenance
Every score carries where it came from, in labels: requested_model and the model
that actually answered, request_id, latency_ms, usage, and local_cache_hit.
local_cache_hit refers to newsscore's own SQLite cache, not any provider-side
cache: a hit means no request was made at all. Run-level result.usage counts only
what this run actually spent, so cached articles contribute nothing to it, while what
they originally cost stays on the article for auditing.
result.usage # {"input_tokens": 1663, "output_tokens": 279, "requests": 2}
Two different confidences
ArticleScore.confidence is the scorer's own calibrated confidence in one article's
answer. ScoreResult.confidence — also available as the clearer
ScoreResult.evidence_confidence — is an aggregate evidence measure that grows with
how much weighted evidence there is. They are different quantities. Neither is a
probability that a trade will be profitable, and one is not a substitute for the other
in a review threshold.
When a run goes wrong
A sentiment of 0.0 can mean two completely different things, so every result carries
a status and a reconcilable set of counts. Check the status before acting on the
score.
status |
meaning | score is |
|---|---|---|
ok |
every article scored, and the aggregate rests on real weight | a reading; 0.0 here is genuinely neutral news |
partial |
some articles failed, the rest carry weight | a reading over fewer articles than you asked for |
no_weight |
articles scored, but nothing carried weight (all zero confidence or relevance) | not a reading; 0.0 means "no evidence" |
all_failed |
every scoring attempt failed | not a reading |
no_articles |
nothing matched the window | not a reading |
result = scorer.score("AAPL")
if not result.ok: # True only for status == "ok"
print(result.status, result.counts, result.failures)
counts reconciles exactly: fetched == deduplicated + filtered + submitted and
submitted == scored + failed, counted in scoring tasks (one article for one query).
Every task that did not produce a score appears in result.failures with its article
id, an error_type and the number of attempts made.
Failures are isolated, not fatal
One bad article never costs you its batch. A scoring function reports a single failure
by returning a ScoreItemError; one that raises instead is retried, then split in half
repeatedly until the offending article is alone, so its siblings still get scored — and
successes are cached even when siblings fail, so a rerun never pays for them twice.
Retryable failures (timeouts, rate limits, 5xx) are retried with exponential backoff and
jitter: NewsScorer(retries=2, retry_backoff=0.5). Run-wide problems such as bad
credentials stop the run instead of repeating the same rejection once per headline.
Strict exits for pipelines
By default the CLI always exits 0. --fail-on turns the status into an exit code while
still writing the JSON and the diagnostics:
newsscore score AAPL --json --fail-on unusable # exit 3 if there is no usable aggregate
newsscore score AAPL --json --fail-on partial # exit 4 as well if any article failed
Asking for a scorer by name that cannot run here is an error, not a downgrade:
--scorer jev without TYPESAFE_API_KEY exits 1 with a message rather than silently
scoring with the keyword lexicon. Only the automatic default falls back, and only when
you did not name a scorer.
Where things are stored
newsscore writes nothing you did not ask for. Run newsscore doctor to print the
actual paths on your machine.
| what | where | how to change it |
|---|---|---|
| fetched articles | nowhere — printed to the terminal | --out FILE to save them as JSON, or --json and redirect |
| scored results | nowhere — printed to the terminal | --out FILE, or --json and redirect |
| article scores (the cache) | platformdirs.user_cache_dir("newsscore")/scores.sqlite |
NEWSSCORE_CACHE; --no-cache / cache=False to skip it; newsscore cache-clear to wipe it |
| saved sources | platformdirs.user_config_dir("newsscore")/sources.json |
NEWSSCORE_CONFIG; newsscore config-path to print it |
| API keys | .env in the working directory |
NEWSSCORE_ENV, or real environment variables |
Saving articles and results
newsscore fetch AAPL -d 7 --out aapl-articles.json # every article, one JSON array
newsscore score AAPL --out runs/aapl-2026-09-18.json # aggregate + every scored article, dirs created
--out creates missing parent directories and always writes UTF-8, which the shell's
own > redirection does not reliably do on Windows. In Python, result.to_dict() and
article.to_dict() give the same structures, so you can send them wherever you like:
import json
result = scorer.score("AAPL", days=7)
Path("aapl.json").write_text(json.dumps(result.to_dict(), indent=2, default=str), encoding="utf-8")
The score cache
Scores — not articles — are cached under (scorer name, query, article id), so re-running
a query only pays for articles you have not seen before, and back-tests replay for free.
It is an ordinary SQLite file; point NEWSSCORE_CACHE at a project directory if you want
one cache per research project rather than one per user.
Development
uv sync # creates .venv with the package, the Jev SDK and test tools
uv run newsscore doctor
uv run pytest
Or with plain pip: pip install -e ".[jev,dev]" then pytest.
Test artefacts (per-test config files and score caches) are written under
E:\test_data\jev_sentiment when that drive exists; set NEWSSCORE_TEST_DATA to move them,
or they fall back to pytest's temporary directory.
Status: what has and has not been tested live
This is a 0.2 release built and verified on one machine with the API plans its author happens to have. Every source has unit tests against fixture payloads that follow the provider's documented response shape, but only some have been run against the real endpoint.
| Component | Unit tests | Live-tested | Notes |
|---|---|---|---|
finnhub |
yes | yes | free tier, 237 AAPL articles / 7 days |
polygon / massive |
yes | yes | free tier via api.massive.com, pagination exercised |
newsapi |
yes | yes | developer plan (24 h delay) |
yahoo RSS |
yes | yes | keyless |
jev scorer |
yes (fake client) | yes | 310 articles scored with a real TYPESAFE_API_KEY |
alpha_vantage |
yes | no | no key available; quota-message handling untested live |
marketaux |
yes | no | no key available; page-size behaviour on the free plan unverified |
tiingo |
yes | no | live call returned 403 on the free plan, so the parser has never seen real data |
generic rss (Atom) |
yes | no | Atom branch only covered by a fixture; RSS 2.0 covered via Yahoo |
keyword scorer |
yes | n/a | offline |
Untested does not mean broken, but field names and pagination details are exactly
where providers drift from their docs. If you hold a key for one of the untested
sources, running newsscore fetch AAPL -s <source> and reporting the outcome is the
single most useful contribution right now.
Contributing
Testers, bug reports and pull requests are all welcome.
- Testers. Run
newsscore doctor, thennewsscore fetchandnewsscore scoreagainst any source you have a key for. Open an issue with the provider, plan tier, the command, and the output (redact your key). A short "works for me" note is useful too. - New sources. Subclass
NewsSource, implementfetch, register the class, add a fixture test intests/test_sources.pyand a row to the sources table above. See "Writing a source" for the shape. - New scorers. Anything matching the
ScoreFncontract can be added tonewsscore/scoring/and registered inSCORERS. - Pull requests. Keep them focused, run
uv run pytestbefore pushing, and update the README table when you change what is tested. Don't commit.env.
See CONTRIBUTING.md for the short version of the workflow.
Caveats
expected_impact is new and unvalidated. On a handful of hand-written headlines it
behaves sensibly — effusive but immaterial CEO language comes back low, a revenue
restatement comes back high — but on that sample impact and the magnitude of the
sentiment score did not visibly come apart, so nothing here demonstrates that it
carries information beyond "how strong is this news". Build a labelled set before you
let high carry a 3× weight in anything that trades.
Jev launched in September 2026 and its accuracy on financial text has not been
independently benchmarked. Build a small hand-labelled set and compare jev
against your alternatives before trusting any signal. Headline sentiment on large
caps is largely priced in within minutes; treat the aggregate as a research input,
not a trade trigger.
Metadata
Release files for newsscore 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| newsscore-0.2.0.tar.gz | 98.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| newsscore-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 175.5 kB
Release files / newsscore-0.2.0.tar.gz
| Download URL | newsscore-0.2.0.tar.gz |
|---|---|
| Size | 98.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ebed67e4a274c9c21e31c6892507024a336f871f2c87bac9449653b0c10530d7
|
|
BLAKE2b-256 checksum How to use checksums |
c8a2debcd62b80bf3471bae8299276a586251dd08d16e743352935ee0059131c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / newsscore-0.2.0-py3-none-any.whl
| Download URL | newsscore-0.2.0-py3-none-any.whl |
|---|---|
| Size | 76.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e715a6235eb455a9aacf72ed72f567ff3055cd98cb7f87cd61454e50f1bcfbb3
|
|
BLAKE2b-256 checksum How to use checksums |
f4cfa74a5711823a35e2310b50cb281989adc5e103b1a6124d751a89ad830257
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log