newsscore 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- newsscore/__init__.py +42 -0
- newsscore/_version.py +1 -0
- newsscore/aggregate.py +78 -0
- newsscore/cache.py +99 -0
- newsscore/cli.py +312 -0
- newsscore/config.py +151 -0
- newsscore/http.py +21 -0
- newsscore/models.py +163 -0
- newsscore/scorer.py +299 -0
- newsscore/scoring/__init__.py +56 -0
- newsscore/scoring/jev.py +173 -0
- newsscore/scoring/keyword.py +94 -0
- newsscore/scoring/protocol.py +119 -0
- newsscore/sources/__init__.py +68 -0
- newsscore/sources/alpha_vantage.py +55 -0
- newsscore/sources/base.py +154 -0
- newsscore/sources/finnhub.py +43 -0
- newsscore/sources/marketaux.py +54 -0
- newsscore/sources/newsapi.py +59 -0
- newsscore/sources/polygon.py +71 -0
- newsscore/sources/rss.py +103 -0
- newsscore/sources/tiingo.py +45 -0
- newsscore-0.1.0.dist-info/METADATA +365 -0
- newsscore-0.1.0.dist-info/RECORD +27 -0
- newsscore-0.1.0.dist-info/WHEEL +4 -0
- newsscore-0.1.0.dist-info/entry_points.txt +2 -0
- newsscore-0.1.0.dist-info/licenses/LICENSE +21 -0
newsscore/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""newsscore: news sentiment scoring for stocks with a pluggable scorer.
|
|
2
|
+
|
|
3
|
+
from newsscore import NewsScorer
|
|
4
|
+
|
|
5
|
+
scorer = NewsScorer() # Jev if TYPESAFE_API_KEY is set, else keyword scorer
|
|
6
|
+
scorer.source_add("yahoo") # keyless RSS, good for a first try
|
|
7
|
+
print(scorer.score("AAPL").score)
|
|
8
|
+
|
|
9
|
+
See ``newsscore.scoring.protocol`` for how to plug in your own scoring function.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .aggregate import Aggregate, AggregateFn, aggregate, make_aggregator
|
|
14
|
+
from .cache import ScoreCache
|
|
15
|
+
from .config import load_env
|
|
16
|
+
from .models import Article, ArticleScore, ScoredArticle, ScoreResult
|
|
17
|
+
from .scorer import NewsScorer
|
|
18
|
+
from .scoring import JevScorer, KeywordScorer, ScoreFn, per_article
|
|
19
|
+
from .sources import SOURCE_TYPES, NewsSource, SourceError, register
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"NewsScorer",
|
|
24
|
+
"Article",
|
|
25
|
+
"ArticleScore",
|
|
26
|
+
"ScoredArticle",
|
|
27
|
+
"ScoreResult",
|
|
28
|
+
"ScoreFn",
|
|
29
|
+
"per_article",
|
|
30
|
+
"JevScorer",
|
|
31
|
+
"KeywordScorer",
|
|
32
|
+
"NewsSource",
|
|
33
|
+
"SourceError",
|
|
34
|
+
"SOURCE_TYPES",
|
|
35
|
+
"register",
|
|
36
|
+
"Aggregate",
|
|
37
|
+
"AggregateFn",
|
|
38
|
+
"aggregate",
|
|
39
|
+
"make_aggregator",
|
|
40
|
+
"ScoreCache",
|
|
41
|
+
"load_env",
|
|
42
|
+
]
|
newsscore/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
newsscore/aggregate.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Turn many per-article scores into one number.
|
|
2
|
+
|
|
3
|
+
The default scheme is deliberately simple and fully described here so it can be
|
|
4
|
+
reasoned about, back-tested and replaced:
|
|
5
|
+
|
|
6
|
+
weight_i = confidence_i * relevance_i * 0.5 ** (age_hours_i / half_life_hours)
|
|
7
|
+
score = sum(weight_i * score_i) / sum(weight_i)
|
|
8
|
+
confidence = 1 - exp(-sum(weight_i) / saturation)
|
|
9
|
+
|
|
10
|
+
Older articles count less (exponential decay), unsure or off-topic articles count
|
|
11
|
+
less (their weights), and confidence grows with the amount of weighted evidence
|
|
12
|
+
but never exceeds 1. With ``saturation=3`` three fully-weighted fresh articles
|
|
13
|
+
give confidence ~0.63; ten give ~0.96.
|
|
14
|
+
|
|
15
|
+
Plug in your own with ``NewsScorer(aggregate_fn=...)``; it receives the scored
|
|
16
|
+
articles and the reference time and must return an :class:`Aggregate`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from functools import partial
|
|
26
|
+
from typing import Callable, Sequence
|
|
27
|
+
|
|
28
|
+
from .models import ScoredArticle
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Aggregate:
|
|
33
|
+
score: float
|
|
34
|
+
confidence: float
|
|
35
|
+
by_source: dict[str, float] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
AggregateFn = Callable[[Sequence[ScoredArticle], datetime], Aggregate]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def aggregate(
|
|
42
|
+
scored: Sequence[ScoredArticle],
|
|
43
|
+
now: datetime,
|
|
44
|
+
*,
|
|
45
|
+
half_life_hours: float = 48.0,
|
|
46
|
+
saturation: float = 3.0,
|
|
47
|
+
) -> Aggregate:
|
|
48
|
+
"""Confidence- and relevance-weighted mean with exponential time decay."""
|
|
49
|
+
if not scored:
|
|
50
|
+
return Aggregate(score=0.0, confidence=0.0)
|
|
51
|
+
|
|
52
|
+
total_w = 0.0
|
|
53
|
+
total_ws = 0.0
|
|
54
|
+
per_source_w: dict[str, float] = defaultdict(float)
|
|
55
|
+
per_source_ws: dict[str, float] = defaultdict(float)
|
|
56
|
+
|
|
57
|
+
for item in scored:
|
|
58
|
+
age_hours = max(0.0, (now - item.article.published).total_seconds() / 3600.0)
|
|
59
|
+
decay = 0.5 ** (age_hours / half_life_hours) if half_life_hours > 0 else 1.0
|
|
60
|
+
w = item.score.weight * decay
|
|
61
|
+
if w <= 0.0:
|
|
62
|
+
continue
|
|
63
|
+
total_w += w
|
|
64
|
+
total_ws += w * item.score.score
|
|
65
|
+
per_source_w[item.article.source] += w
|
|
66
|
+
per_source_ws[item.article.source] += w * item.score.score
|
|
67
|
+
|
|
68
|
+
if total_w == 0.0:
|
|
69
|
+
return Aggregate(score=0.0, confidence=0.0)
|
|
70
|
+
|
|
71
|
+
by_source = {name: per_source_ws[name] / per_source_w[name] for name in per_source_w}
|
|
72
|
+
confidence = 1.0 - math.exp(-total_w / saturation) if saturation > 0 else 1.0
|
|
73
|
+
return Aggregate(score=total_ws / total_w, confidence=confidence, by_source=by_source)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_aggregator(half_life_hours: float = 48.0, saturation: float = 3.0) -> AggregateFn:
|
|
77
|
+
"""Bind parameters so the result matches the :data:`AggregateFn` signature."""
|
|
78
|
+
return partial(aggregate, half_life_hours=half_life_hours, saturation=saturation)
|
newsscore/cache.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SQLite cache of per-article scores.
|
|
2
|
+
|
|
3
|
+
Scoring is the expensive step (Jev calls cost money, user models cost time), so
|
|
4
|
+
every :class:`~newsscore.ArticleScore` is stored under
|
|
5
|
+
``(scorer_name, query, article_id)``. Re-running a query only scores articles that
|
|
6
|
+
have not been seen before, and back-tests can replay from the cache for free.
|
|
7
|
+
|
|
8
|
+
The cache is synchronous on purpose: SQLite calls here take microseconds and a
|
|
9
|
+
threadpool hop would cost more than it saves.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
from platformdirs import user_cache_dir
|
|
22
|
+
|
|
23
|
+
from .models import ArticleScore
|
|
24
|
+
|
|
25
|
+
_SCHEMA = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS scores (
|
|
27
|
+
scorer TEXT NOT NULL,
|
|
28
|
+
query TEXT NOT NULL,
|
|
29
|
+
article_id TEXT NOT NULL,
|
|
30
|
+
payload TEXT NOT NULL,
|
|
31
|
+
created REAL NOT NULL,
|
|
32
|
+
PRIMARY KEY (scorer, query, article_id)
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_cache_path() -> Path:
|
|
38
|
+
override = os.environ.get("NEWSSCORE_CACHE")
|
|
39
|
+
if override:
|
|
40
|
+
return Path(override).expanduser()
|
|
41
|
+
return Path(user_cache_dir("newsscore")) / "scores.sqlite"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScoreCache:
|
|
45
|
+
def __init__(self, path: Path | str | None = None) -> None:
|
|
46
|
+
self.path = Path(path) if path else default_cache_path()
|
|
47
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
49
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
50
|
+
self._conn.execute(_SCHEMA)
|
|
51
|
+
self._conn.commit()
|
|
52
|
+
|
|
53
|
+
def get_many(self, scorer: str, query: str, ids: Iterable[str]) -> dict[str, ArticleScore]:
|
|
54
|
+
ids = list(ids)
|
|
55
|
+
if not ids:
|
|
56
|
+
return {}
|
|
57
|
+
found: dict[str, ArticleScore] = {}
|
|
58
|
+
# SQLite caps bound parameters; chunk to stay well under the limit.
|
|
59
|
+
for start in range(0, len(ids), 500):
|
|
60
|
+
chunk = ids[start : start + 500]
|
|
61
|
+
marks = ",".join("?" * len(chunk))
|
|
62
|
+
rows = self._conn.execute(
|
|
63
|
+
f"SELECT article_id, payload FROM scores WHERE scorer=? AND query=? AND article_id IN ({marks})",
|
|
64
|
+
(scorer, query, *chunk),
|
|
65
|
+
)
|
|
66
|
+
for article_id, payload in rows:
|
|
67
|
+
found[article_id] = ArticleScore.from_dict(json.loads(payload))
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
def put_many(self, scorer: str, query: str, items: Mapping[str, ArticleScore]) -> None:
|
|
71
|
+
if not items:
|
|
72
|
+
return
|
|
73
|
+
now = time.time()
|
|
74
|
+
self._conn.executemany(
|
|
75
|
+
"INSERT OR REPLACE INTO scores (scorer, query, article_id, payload, created) VALUES (?,?,?,?,?)",
|
|
76
|
+
[
|
|
77
|
+
(scorer, query, article_id, json.dumps(score.to_dict(), default=str), now)
|
|
78
|
+
for article_id, score in items.items()
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
self._conn.commit()
|
|
82
|
+
|
|
83
|
+
def clear(self, scorer: str | None = None) -> int:
|
|
84
|
+
cur = (
|
|
85
|
+
self._conn.execute("DELETE FROM scores WHERE scorer=?", (scorer,))
|
|
86
|
+
if scorer
|
|
87
|
+
else self._conn.execute("DELETE FROM scores")
|
|
88
|
+
)
|
|
89
|
+
self._conn.commit()
|
|
90
|
+
return cur.rowcount
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._conn.close()
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> "ScoreCache":
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *exc: object) -> None:
|
|
99
|
+
self.close()
|
newsscore/cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""``newsscore`` command line.
|
|
2
|
+
|
|
3
|
+
newsscore source add finnhub --api-key KEY
|
|
4
|
+
newsscore source add yahoo
|
|
5
|
+
newsscore source list
|
|
6
|
+
newsscore score AAPL --days 7
|
|
7
|
+
newsscore score AAPL -s finnhub -s yahoo --json
|
|
8
|
+
newsscore fetch AAPL --out news.json
|
|
9
|
+
|
|
10
|
+
Nothing is written unless you ask for it: ``fetch`` and ``score`` print to stdout,
|
|
11
|
+
and ``--out FILE`` saves the same payload as JSON instead. Article *scores* are
|
|
12
|
+
cached in SQLite so a repeated query only pays for new articles, and saved sources
|
|
13
|
+
live in a per-user JSON file. ``newsscore doctor`` prints every path in use; the
|
|
14
|
+
``NEWSSCORE_CACHE``, ``NEWSSCORE_CONFIG`` and ``NEWSSCORE_ENV`` variables move them.
|
|
15
|
+
|
|
16
|
+
API keys may be placed in a ``.env`` file in the working directory; it is loaded
|
|
17
|
+
on every invocation without overriding real environment variables.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import importlib.util
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Annotated, Optional
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
from .cache import ScoreCache, default_cache_path
|
|
34
|
+
from .config import SourceSpec, SourceStore, config_path, env_candidates, load_env
|
|
35
|
+
from .scorer import NewsScorer
|
|
36
|
+
from .scoring import SCORERS
|
|
37
|
+
from .sources import SOURCE_TYPES, SourceError, make_source
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
help="News sentiment scoring for stocks. Mainstream news APIs in, one score out.",
|
|
41
|
+
invoke_without_command=True,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
)
|
|
44
|
+
source_app = typer.Typer(help="Manage saved news sources.", no_args_is_help=True)
|
|
45
|
+
app.add_typer(source_app, name="source")
|
|
46
|
+
|
|
47
|
+
SourcesOpt = Annotated[
|
|
48
|
+
Optional[list[str]],
|
|
49
|
+
typer.Option("--source", "-s", help="Saved source name; repeat for several. Omit for all."),
|
|
50
|
+
]
|
|
51
|
+
DaysOpt = Annotated[float, typer.Option("--days", "-d", help="Look-back window in days.")]
|
|
52
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
|
|
53
|
+
OutOpt = Annotated[
|
|
54
|
+
Optional[Path],
|
|
55
|
+
typer.Option("--out", metavar="FILE", help="Write the result to FILE as JSON instead of printing it."),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.callback()
|
|
60
|
+
def _main(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show warnings from sources.")] = False,
|
|
63
|
+
version: Annotated[bool, typer.Option("--version", help="Print version and exit.", is_eager=True)] = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
if version:
|
|
66
|
+
typer.echo(__version__)
|
|
67
|
+
raise typer.Exit()
|
|
68
|
+
if ctx.invoked_subcommand is None:
|
|
69
|
+
typer.echo(ctx.get_help())
|
|
70
|
+
raise typer.Exit()
|
|
71
|
+
logging.basicConfig(level=logging.INFO if verbose else logging.ERROR, format="%(levelname)s %(message)s")
|
|
72
|
+
load_env()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---- source management ------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@source_app.command("add")
|
|
79
|
+
def source_add(
|
|
80
|
+
type_: Annotated[str, typer.Argument(metavar="TYPE", help="Source type; see `newsscore source types`.")],
|
|
81
|
+
name: Annotated[Optional[str], typer.Option("--name", "-n", help="Name to save under (default: the type).")] = None,
|
|
82
|
+
api_key: Annotated[Optional[str], typer.Option("--api-key", "-k", help="Provider API key.")] = None,
|
|
83
|
+
option: Annotated[
|
|
84
|
+
Optional[list[str]], typer.Option("--option", "-o", metavar="KEY=VALUE", help="Provider option; repeatable.")
|
|
85
|
+
] = None,
|
|
86
|
+
replace: Annotated[bool, typer.Option("--replace", help="Overwrite a source with the same name.")] = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Save a news source to the local config."""
|
|
89
|
+
if type_ not in SOURCE_TYPES:
|
|
90
|
+
_fail(f"unknown type {type_!r}. Known: {', '.join(sorted(SOURCE_TYPES))}")
|
|
91
|
+
options = _parse_options(option or [])
|
|
92
|
+
spec = SourceSpec(type=type_, name=name or type_, api_key=api_key, options=options)
|
|
93
|
+
try: # construct once to surface bad options early; a missing key is only a warning
|
|
94
|
+
make_source(type_, api_key=api_key, name=spec.name, **options)
|
|
95
|
+
except SourceError as exc:
|
|
96
|
+
if "API key required" not in str(exc):
|
|
97
|
+
_fail(str(exc))
|
|
98
|
+
typer.secho(f"warning: {exc}. Saved anyway; set the env var before use.", fg="yellow", err=True)
|
|
99
|
+
try:
|
|
100
|
+
SourceStore().add(spec, replace=replace)
|
|
101
|
+
except KeyError as exc:
|
|
102
|
+
_fail(f"{exc.args[0]}; pass --replace to overwrite")
|
|
103
|
+
typer.echo(f"saved source {spec.name!r} ({type_}) to {config_path()}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@source_app.command("list")
|
|
107
|
+
def source_list() -> None:
|
|
108
|
+
"""Show saved sources."""
|
|
109
|
+
specs = SourceStore().load()
|
|
110
|
+
if not specs:
|
|
111
|
+
typer.echo("no saved sources. Try: newsscore source add yahoo")
|
|
112
|
+
return
|
|
113
|
+
rows = [(s.name, s.type, _mask(s.api_key), json.dumps(s.options) if s.options else "") for s in specs.values()]
|
|
114
|
+
_table(("NAME", "TYPE", "API KEY", "OPTIONS"), rows)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@source_app.command("remove")
|
|
118
|
+
def source_remove(name: Annotated[str, typer.Argument(help="Saved source name.")]) -> None:
|
|
119
|
+
"""Delete a saved source."""
|
|
120
|
+
try:
|
|
121
|
+
SourceStore().remove(name)
|
|
122
|
+
except KeyError as exc:
|
|
123
|
+
_fail(exc.args[0])
|
|
124
|
+
typer.echo(f"removed {name!r}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@source_app.command("types")
|
|
128
|
+
def source_types() -> None:
|
|
129
|
+
"""List supported source types."""
|
|
130
|
+
rows = [
|
|
131
|
+
(t, "no" if not cls.requires_key else "yes", cls.env_key or "", cls.query_kind)
|
|
132
|
+
for t, cls in sorted(SOURCE_TYPES.items())
|
|
133
|
+
]
|
|
134
|
+
_table(("TYPE", "NEEDS KEY", "ENV VAR", "QUERY"), rows)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---- fetching and scoring ---------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def fetch(
|
|
142
|
+
query: Annotated[str, typer.Argument(help="Ticker symbol or keyword.")],
|
|
143
|
+
source: SourcesOpt = None,
|
|
144
|
+
days: DaysOpt = 7,
|
|
145
|
+
as_json: JsonOpt = False,
|
|
146
|
+
out: OutOpt = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""List recent articles without scoring them.
|
|
149
|
+
|
|
150
|
+
Articles are printed and then forgotten; pass --out FILE to keep them as JSON.
|
|
151
|
+
"""
|
|
152
|
+
scorer = NewsScorer.from_config(score_fn="keyword", cache=False)
|
|
153
|
+
articles = _run(scorer, scorer.afetch(query, days=days, sources=source))
|
|
154
|
+
payload = [a.to_dict() for a in articles]
|
|
155
|
+
if out:
|
|
156
|
+
_write_json(out, payload)
|
|
157
|
+
typer.echo(f"wrote {len(articles)} article(s) to {out}")
|
|
158
|
+
return
|
|
159
|
+
if as_json:
|
|
160
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
161
|
+
return
|
|
162
|
+
if not articles:
|
|
163
|
+
typer.echo("no articles found")
|
|
164
|
+
return
|
|
165
|
+
for a in articles:
|
|
166
|
+
typer.echo(f"{a.published:%Y-%m-%d %H:%M} [{a.source}] {a.title}")
|
|
167
|
+
typer.echo(f"\n{len(articles)} article(s)")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def score(
|
|
172
|
+
query: Annotated[str, typer.Argument(help="Ticker symbol or keyword.")],
|
|
173
|
+
source: SourcesOpt = None,
|
|
174
|
+
days: DaysOpt = 7,
|
|
175
|
+
scorer_name: Annotated[
|
|
176
|
+
Optional[str], typer.Option("--scorer", help=f"One of: {', '.join(sorted(SCORERS))}. Default: jev if configured, else keyword.")
|
|
177
|
+
] = None,
|
|
178
|
+
half_life: Annotated[float, typer.Option("--half-life", help="Decay half-life in hours for aggregation.")] = 48.0,
|
|
179
|
+
no_cache: Annotated[bool, typer.Option("--no-cache", help="Do not read or write the score cache.")] = False,
|
|
180
|
+
articles: Annotated[int, typer.Option("--articles", "-a", help="Show the N most recent scored articles.")] = 0,
|
|
181
|
+
as_json: JsonOpt = False,
|
|
182
|
+
out: OutOpt = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Fetch, score and aggregate news sentiment for QUERY.
|
|
185
|
+
|
|
186
|
+
The result is printed; pass --out FILE to keep the full JSON (every scored
|
|
187
|
+
article included) instead.
|
|
188
|
+
"""
|
|
189
|
+
if scorer_name and scorer_name not in SCORERS:
|
|
190
|
+
_fail(f"unknown scorer {scorer_name!r}. Known: {', '.join(sorted(SCORERS))}")
|
|
191
|
+
scorer = NewsScorer.from_config(score_fn=scorer_name, cache=not no_cache, half_life_hours=half_life)
|
|
192
|
+
result = _run(scorer, scorer.ascore(query, days=days, sources=source))
|
|
193
|
+
|
|
194
|
+
if out:
|
|
195
|
+
_write_json(out, result.to_dict())
|
|
196
|
+
typer.echo(f"wrote {result.n_articles} scored article(s) to {out}")
|
|
197
|
+
return
|
|
198
|
+
if as_json:
|
|
199
|
+
typer.echo(json.dumps(result.to_dict(), indent=2, default=str))
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
typer.echo(f"query {result.query}")
|
|
203
|
+
typer.echo(f"window {result.since:%Y-%m-%d} .. {result.until:%Y-%m-%d} ({days:g} days)")
|
|
204
|
+
typer.echo(f"scorer {scorer.scorer_name or type(scorer.score_fn).__name__}")
|
|
205
|
+
typer.echo(f"articles {result.n_articles}")
|
|
206
|
+
typer.echo(f"score {result.score:+.3f} (-1 bearish .. +1 bullish)")
|
|
207
|
+
typer.echo(f"confidence {result.confidence:.3f}")
|
|
208
|
+
if result.by_source:
|
|
209
|
+
typer.echo("by source " + " ".join(f"{k}={v:+.2f}" for k, v in sorted(result.by_source.items())))
|
|
210
|
+
for message in result.errors:
|
|
211
|
+
typer.secho(f"warning {message}", fg="yellow", err=True)
|
|
212
|
+
if articles:
|
|
213
|
+
typer.echo("")
|
|
214
|
+
for item in result.articles[:articles]:
|
|
215
|
+
a, s = item.article, item.score
|
|
216
|
+
typer.echo(f"{s.score:+.2f} c={s.confidence:.2f} r={s.relevance:.2f} {a.published:%m-%d %H:%M} [{a.source}] {a.title}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command("config-path")
|
|
220
|
+
def show_config_path() -> None:
|
|
221
|
+
"""Print where saved sources are stored."""
|
|
222
|
+
typer.echo(str(config_path()))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command("cache-clear")
|
|
226
|
+
def cache_clear(
|
|
227
|
+
scorer_name: Annotated[Optional[str], typer.Option("--scorer", help="Only clear this scorer's entries.")] = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Delete cached article scores."""
|
|
230
|
+
with ScoreCache() as cache:
|
|
231
|
+
n = cache.clear(scorer_name)
|
|
232
|
+
typer.echo(f"removed {n} cached score(s) from {ScoreCache().path}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def doctor() -> None:
|
|
237
|
+
"""Check what is configured on this machine."""
|
|
238
|
+
env_file = next((p for p in env_candidates() if p.is_file()), None)
|
|
239
|
+
typer.echo(f"env file {env_file or 'none found (looked in ' + ', '.join(str(p) for p in env_candidates()) + ')'}")
|
|
240
|
+
typer.echo(f"config file {config_path()} ({'exists' if config_path().exists() else 'missing'})")
|
|
241
|
+
cache = default_cache_path()
|
|
242
|
+
typer.echo(f"score cache {cache} ({'exists' if cache.exists() else 'created on first score'})")
|
|
243
|
+
typer.echo(f"saved sources {', '.join(SourceStore().load()) or 'none'}")
|
|
244
|
+
missing = []
|
|
245
|
+
if importlib.util.find_spec("typesafe_sdk") is None:
|
|
246
|
+
missing.append("typesafe-sdk not installed (pip install 'newsscore[jev]' or uv sync)")
|
|
247
|
+
if not os.environ.get("TYPESAFE_API_KEY"):
|
|
248
|
+
missing.append("TYPESAFE_API_KEY not set")
|
|
249
|
+
typer.echo(f"jev scorer {'ready' if not missing else 'unavailable: ' + '; '.join(missing)}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---- helpers ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run(scorer: NewsScorer, coro): # type: ignore[no-untyped-def]
|
|
256
|
+
async def go(): # type: ignore[no-untyped-def]
|
|
257
|
+
try:
|
|
258
|
+
return await coro
|
|
259
|
+
finally:
|
|
260
|
+
await scorer.aclose()
|
|
261
|
+
|
|
262
|
+
return asyncio.run(go())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _write_json(path: Path, payload: object) -> None:
|
|
266
|
+
"""Save `payload` as UTF-8 JSON, creating parent directories as needed."""
|
|
267
|
+
path = path.expanduser()
|
|
268
|
+
if path.parent != Path(""):
|
|
269
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
try:
|
|
271
|
+
path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
_fail(f"could not write {path}: {exc}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_options(items: list[str]) -> dict[str, object]:
|
|
277
|
+
options: dict[str, object] = {}
|
|
278
|
+
for item in items:
|
|
279
|
+
key, sep, value = item.partition("=")
|
|
280
|
+
if not sep or not key:
|
|
281
|
+
_fail(f"bad --option {item!r}; expected KEY=VALUE")
|
|
282
|
+
lowered = value.lower()
|
|
283
|
+
options[key] = True if lowered == "true" else False if lowered == "false" else _number_or_str(value)
|
|
284
|
+
return options
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _number_or_str(value: str) -> object:
|
|
288
|
+
try:
|
|
289
|
+
return int(value)
|
|
290
|
+
except ValueError:
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _mask(key: str | None) -> str:
|
|
295
|
+
if not key:
|
|
296
|
+
return "(env)"
|
|
297
|
+
return key if len(key) <= 6 else f"{key[:3]}...{key[-3:]}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _table(header: tuple[str, ...], rows: list[tuple[str, ...]]) -> None:
|
|
301
|
+
widths = [max(len(str(r[i])) for r in (header, *rows)) for i in range(len(header))]
|
|
302
|
+
for row in (header, *rows):
|
|
303
|
+
typer.echo(" ".join(str(cell).ljust(w) for cell, w in zip(row, widths)).rstrip())
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fail(message: str) -> None:
|
|
307
|
+
typer.secho(f"error: {message}", fg="red", err=True)
|
|
308
|
+
raise typer.Exit(code=1)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__": # pragma: no cover
|
|
312
|
+
app()
|
newsscore/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Per-user store of configured news sources.
|
|
2
|
+
|
|
3
|
+
Location (first match wins):
|
|
4
|
+
1. ``$NEWSSCORE_CONFIG``
|
|
5
|
+
2. ``platformdirs.user_config_dir("newsscore")/sources.json``
|
|
6
|
+
(``%APPDATA%\\newsscore`` on Windows, ``~/.config/newsscore`` on Linux,
|
|
7
|
+
``~/Library/Application Support/newsscore`` on macOS)
|
|
8
|
+
|
|
9
|
+
Shape::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"sources": {
|
|
13
|
+
"finnhub": {"type": "finnhub", "api_key": "abc", "options": {}},
|
|
14
|
+
"yahoo": {"type": "yahoo", "api_key": null, "options": {}}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
``api_key`` may be null; the source then falls back to its environment variable.
|
|
19
|
+
|
|
20
|
+
API keys can also live in a ``.env`` file (``KEY=VALUE`` lines, ``#`` comments,
|
|
21
|
+
optional quotes). :func:`load_env` is called by the CLI and by
|
|
22
|
+
``NewsScorer.from_config()``; it never overrides variables already set in the
|
|
23
|
+
environment. Lookup: ``$NEWSSCORE_ENV`` alone if set, else ``./.env``, then ``<config dir>/.env``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from platformdirs import user_config_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def env_candidates() -> list[Path]:
|
|
38
|
+
"""Where :func:`load_env` looks. ``NEWSSCORE_ENV``, when set, is the only candidate."""
|
|
39
|
+
override = os.environ.get("NEWSSCORE_ENV")
|
|
40
|
+
if override:
|
|
41
|
+
return [Path(override).expanduser()]
|
|
42
|
+
return [Path.cwd() / ".env", Path(user_config_dir("newsscore")) / ".env"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
46
|
+
"""Parse ``KEY=VALUE`` lines. Supports ``export KEY=...``, quotes and ``#`` comments."""
|
|
47
|
+
values: dict[str, str] = {}
|
|
48
|
+
for raw in text.splitlines():
|
|
49
|
+
line = raw.strip()
|
|
50
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
51
|
+
continue
|
|
52
|
+
if line.startswith("export "):
|
|
53
|
+
line = line[len("export "):]
|
|
54
|
+
key, _, value = line.partition("=")
|
|
55
|
+
key, value = key.strip(), value.strip()
|
|
56
|
+
if not key:
|
|
57
|
+
continue
|
|
58
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
59
|
+
value = value[1:-1]
|
|
60
|
+
elif value.startswith("#"):
|
|
61
|
+
value = "" # blank value followed by a comment
|
|
62
|
+
else:
|
|
63
|
+
value = value.split(" #", 1)[0].rstrip()
|
|
64
|
+
values[key] = value
|
|
65
|
+
return values
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_env(path: Path | str | None = None, *, override: bool = False) -> Path | None:
|
|
69
|
+
"""Load the first ``.env`` found into ``os.environ``. Returns the path used, or ``None``.
|
|
70
|
+
|
|
71
|
+
Empty values are skipped so a template with blank keys is harmless. Existing
|
|
72
|
+
environment variables win unless ``override=True``.
|
|
73
|
+
"""
|
|
74
|
+
candidates = [Path(path).expanduser()] if path else env_candidates()
|
|
75
|
+
for candidate in candidates:
|
|
76
|
+
if not candidate.is_file():
|
|
77
|
+
continue
|
|
78
|
+
for key, value in parse_env(candidate.read_text(encoding="utf-8")).items():
|
|
79
|
+
if value and (override or key not in os.environ):
|
|
80
|
+
os.environ[key] = value
|
|
81
|
+
return candidate
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def config_path() -> Path:
|
|
86
|
+
override = os.environ.get("NEWSSCORE_CONFIG")
|
|
87
|
+
if override:
|
|
88
|
+
return Path(override).expanduser()
|
|
89
|
+
return Path(user_config_dir("newsscore")) / "sources.json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class SourceSpec:
|
|
94
|
+
type: str
|
|
95
|
+
name: str
|
|
96
|
+
api_key: str | None = None
|
|
97
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
data = asdict(self)
|
|
101
|
+
data.pop("name")
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SourceStore:
|
|
106
|
+
"""Read and write the JSON file. Every method reloads from disk, so the store
|
|
107
|
+
is always consistent with what other processes wrote."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, path: Path | str | None = None) -> None:
|
|
110
|
+
self.path = Path(path) if path else config_path()
|
|
111
|
+
|
|
112
|
+
def load(self) -> dict[str, SourceSpec]:
|
|
113
|
+
if not self.path.exists():
|
|
114
|
+
return {}
|
|
115
|
+
with self.path.open("r", encoding="utf-8") as fh:
|
|
116
|
+
data = json.load(fh) or {}
|
|
117
|
+
specs: dict[str, SourceSpec] = {}
|
|
118
|
+
for name, item in (data.get("sources") or {}).items():
|
|
119
|
+
specs[name] = SourceSpec(
|
|
120
|
+
type=item["type"],
|
|
121
|
+
name=name,
|
|
122
|
+
api_key=item.get("api_key"),
|
|
123
|
+
options=dict(item.get("options") or {}),
|
|
124
|
+
)
|
|
125
|
+
return specs
|
|
126
|
+
|
|
127
|
+
def save(self, specs: dict[str, SourceSpec]) -> None:
|
|
128
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
payload = {"sources": {name: spec.to_dict() for name, spec in specs.items()}}
|
|
130
|
+
tmp = self.path.with_suffix(".tmp")
|
|
131
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
132
|
+
json.dump(payload, fh, indent=2)
|
|
133
|
+
os.replace(tmp, self.path)
|
|
134
|
+
try: # keys live here; tighten permissions where the OS honours them
|
|
135
|
+
os.chmod(self.path, 0o600)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def add(self, spec: SourceSpec, *, replace: bool = False) -> None:
|
|
140
|
+
specs = self.load()
|
|
141
|
+
if spec.name in specs and not replace:
|
|
142
|
+
raise KeyError(f"source {spec.name!r} already exists (use replace)")
|
|
143
|
+
specs[spec.name] = spec
|
|
144
|
+
self.save(specs)
|
|
145
|
+
|
|
146
|
+
def remove(self, name: str) -> None:
|
|
147
|
+
specs = self.load()
|
|
148
|
+
if name not in specs:
|
|
149
|
+
raise KeyError(f"no source named {name!r}")
|
|
150
|
+
del specs[name]
|
|
151
|
+
self.save(specs)
|