finance-pulse 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.
- finance_pulse/__init__.py +5 -0
- finance_pulse/client.py +327 -0
- finance_pulse/mcp_server.py +270 -0
- finance_pulse/py.typed +0 -0
- finance_pulse-0.1.0.dist-info/METADATA +234 -0
- finance_pulse-0.1.0.dist-info/RECORD +9 -0
- finance_pulse-0.1.0.dist-info/WHEEL +4 -0
- finance_pulse-0.1.0.dist-info/entry_points.txt +3 -0
- finance_pulse-0.1.0.dist-info/licenses/LICENSE +21 -0
finance_pulse/client.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Client for the Finance Pulse API (version 2.0, the /v2 endpoints), served through RapidAPI.
|
|
2
|
+
|
|
3
|
+
Every method returns the API's JSON as plain dicts, unchanged: {"data": ..., "next_cursor": ..., "snapshot": ...}.
|
|
4
|
+
The API reference is at https://fintopic.news/docs/reference.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterator, Mapping, Optional, Sequence, Union
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
PRICING_URL = "https://rapidapi.com/evankos/api/finance-pulse/pricing"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Backend:
|
|
24
|
+
"""Where the API is reached and how a key is sent. The API itself (paths, parameters, responses) is the same
|
|
25
|
+
behind every backend."""
|
|
26
|
+
base_url: str
|
|
27
|
+
key_header: str # the header that carries the key
|
|
28
|
+
key_env: str # environment variable read when no api_key is passed
|
|
29
|
+
signup_url: str # where to get a key
|
|
30
|
+
key_prefix: str = "" # e.g. "Bearer "
|
|
31
|
+
extra_headers: Mapping[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def headers(self, api_key: str) -> dict:
|
|
34
|
+
return {self.key_header: self.key_prefix + api_key, **self.extra_headers}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
RAPIDAPI = Backend(base_url="https://finance-pulse.p.rapidapi.com", key_header="X-RapidAPI-Key",
|
|
38
|
+
key_env="RAPIDAPI_KEY", signup_url=PRICING_URL,
|
|
39
|
+
extra_headers={"X-RapidAPI-Host": "finance-pulse.p.rapidapi.com"})
|
|
40
|
+
BUILD_INTERVAL_SECONDS = 150 # the API builds a new snapshot about this often; polling faster returns the same data
|
|
41
|
+
|
|
42
|
+
Many = Union[str, Sequence[str], None]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FinancePulseError(Exception):
|
|
46
|
+
"""Any failed request. `status` is the HTTP status, or 0 when no answer arrived (timeout, network). `title`,
|
|
47
|
+
`detail` and `param` come from the API's problem+json body when there is one (a gateway's own errors, e.g.
|
|
48
|
+
RapidAPI's 429 for the plan's quota, only have a message). `retry_after` is the Retry-After header in seconds."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, status: int, title: str, detail: Optional[str] = None, param: Optional[str] = None,
|
|
51
|
+
retry_after: Optional[float] = None):
|
|
52
|
+
self.status, self.title, self.detail, self.param, self.retry_after = status, title, detail, param, retry_after
|
|
53
|
+
text = f"{status} {title}" if status else title
|
|
54
|
+
if detail:
|
|
55
|
+
text += f": {detail}"
|
|
56
|
+
if param:
|
|
57
|
+
text += f" (parameter: {param})"
|
|
58
|
+
super().__init__(text)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def transient(self) -> bool:
|
|
62
|
+
"""True when the same request may succeed later: no answer, or a server-side (5xx) error."""
|
|
63
|
+
return self.status == 0 or self.status >= 500
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _many(value: Many) -> Optional[str]:
|
|
67
|
+
"""Several values go out comma-separated in one parameter (the API reads them as OR)."""
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
return value if isinstance(value, str) else ",".join(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _segment(value: str) -> str:
|
|
74
|
+
return quote(str(value), safe="")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FinancePulse:
|
|
78
|
+
"""Finance Pulse API client.
|
|
79
|
+
|
|
80
|
+
fp = FinancePulse() # key from the RAPIDAPI_KEY environment variable
|
|
81
|
+
for s in fp.statements(symbol="NVDA", importance_min="high", limit=5)["data"]:
|
|
82
|
+
print(s["published_at"], s["sentiment"], s["statement"])
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, api_key: Optional[str] = None, *, backend: Backend = RAPIDAPI,
|
|
86
|
+
base_url: Optional[str] = None, timeout: float = 30.0,
|
|
87
|
+
transport: Optional[httpx.BaseTransport] = None):
|
|
88
|
+
api_key = (api_key or os.environ.get(backend.key_env) or "").strip()
|
|
89
|
+
if not api_key:
|
|
90
|
+
raise ValueError(f"No API key: pass api_key= or set {backend.key_env}. "
|
|
91
|
+
f"Get a free key at {backend.signup_url}")
|
|
92
|
+
if not (api_key.isascii() and api_key.isprintable()):
|
|
93
|
+
raise ValueError(f"The API key ({backend.key_env}) contains characters a key cannot have; copy it again")
|
|
94
|
+
self.backend = backend
|
|
95
|
+
# Redirects are followed by hand in get(): httpx would send the key header to whatever host one names.
|
|
96
|
+
self._http = httpx.Client(base_url=base_url or backend.base_url, timeout=timeout, transport=transport,
|
|
97
|
+
headers=backend.headers(api_key))
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
self._http.close()
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> "FinancePulse":
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, *exc) -> None:
|
|
106
|
+
self.close()
|
|
107
|
+
|
|
108
|
+
# ---- transport ---------------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def get(self, path: str, **params: Any) -> dict:
|
|
111
|
+
"""GET any path of the API, e.g. "/v2/meta". None values are dropped, booleans are sent as true/false.
|
|
112
|
+
Raises FinancePulseError for every failure, including timeouts and network errors (status 0)."""
|
|
113
|
+
if not path.startswith("/") or path.startswith("//"):
|
|
114
|
+
raise ValueError("path must start with a single '/': the key is only ever sent to the API's own host")
|
|
115
|
+
query = {key: (str(value).lower() if isinstance(value, bool) else str(value))
|
|
116
|
+
for key, value in params.items() if value is not None}
|
|
117
|
+
try:
|
|
118
|
+
response = self._http.get(path, params=query)
|
|
119
|
+
for _ in range(3): # a merged theme answers 308 to its successor, on the same host
|
|
120
|
+
if not response.has_redirect_location:
|
|
121
|
+
break
|
|
122
|
+
target = response.url.join(response.headers["location"])
|
|
123
|
+
if (target.scheme, target.host, target.port) != (response.url.scheme, response.url.host, response.url.port):
|
|
124
|
+
raise FinancePulseError(response.status_code, "Redirect to another origin refused",
|
|
125
|
+
f"{target.scheme}://{target.netloc.decode()}")
|
|
126
|
+
response = self._http.get(target)
|
|
127
|
+
except httpx.HTTPError as exc:
|
|
128
|
+
raise FinancePulseError(0, "Could not reach the API", f"{type(exc).__name__}: {exc}".rstrip(": ")) from exc
|
|
129
|
+
try:
|
|
130
|
+
body = response.json()
|
|
131
|
+
except ValueError:
|
|
132
|
+
body = None
|
|
133
|
+
if response.is_success:
|
|
134
|
+
if not isinstance(body, dict) or "data" not in body:
|
|
135
|
+
raise FinancePulseError(response.status_code, "Unexpected response",
|
|
136
|
+
"the body is not the API's JSON envelope")
|
|
137
|
+
return body
|
|
138
|
+
if not isinstance(body, dict):
|
|
139
|
+
body = {}
|
|
140
|
+
title = body.get("title") or body.get("message") or body.get("detail") or response.reason_phrase
|
|
141
|
+
detail = body.get("detail") if body.get("title") else None
|
|
142
|
+
try:
|
|
143
|
+
retry_after = max(0.0, float(response.headers.get("retry-after", "")))
|
|
144
|
+
except ValueError: # absent, or an HTTP date
|
|
145
|
+
retry_after = None
|
|
146
|
+
if retry_after is not None and not math.isfinite(retry_after):
|
|
147
|
+
retry_after = None
|
|
148
|
+
raise FinancePulseError(response.status_code, str(title), detail, body.get("param"), retry_after)
|
|
149
|
+
|
|
150
|
+
def _pages(self, path: str, params: dict, max_items: Optional[int]) -> Iterator[dict]:
|
|
151
|
+
seen = 0
|
|
152
|
+
while True:
|
|
153
|
+
page = self.get(path, **params)
|
|
154
|
+
for row in page["data"]:
|
|
155
|
+
yield row
|
|
156
|
+
seen += 1
|
|
157
|
+
if max_items is not None and seen >= max_items:
|
|
158
|
+
return
|
|
159
|
+
if not page.get("next_cursor"):
|
|
160
|
+
return
|
|
161
|
+
params = {**params, "cursor": page["next_cursor"]}
|
|
162
|
+
|
|
163
|
+
# ---- statements --------------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def statements(self, *, symbol: Many = None, sector: Many = None, theme_id: Optional[str] = None,
|
|
166
|
+
topic_id: Optional[str] = None, sentiment: Many = None, importance_min: Optional[str] = None,
|
|
167
|
+
source_type: Many = None, since: Optional[str] = None, until: Optional[str] = None,
|
|
168
|
+
has_image: Optional[bool] = None, limit: int = 50, cursor: Optional[str] = None) -> dict:
|
|
169
|
+
"""Search statements across all topics, newest first by the article's post time. One page.
|
|
170
|
+
symbol, sector, sentiment and source_type take one value or a list (OR)."""
|
|
171
|
+
return self.get("/v2/statements", symbol=_many(symbol), sector=_many(sector), theme_id=theme_id,
|
|
172
|
+
topic_id=topic_id, sentiment=_many(sentiment), importance_min=importance_min,
|
|
173
|
+
source_type=_many(source_type), since=since, until=until, has_image=has_image, limit=limit,
|
|
174
|
+
cursor=cursor)
|
|
175
|
+
|
|
176
|
+
def iter_statements(self, *, max_items: Optional[int] = 500, **filters: Any) -> Iterator[dict]:
|
|
177
|
+
"""Statements across pages (each page is one request). Takes the filters of statements().
|
|
178
|
+
max_items=None reads to the end of the window."""
|
|
179
|
+
filters.setdefault("limit", 100)
|
|
180
|
+
for key in ("symbol", "sector", "sentiment", "source_type"):
|
|
181
|
+
if key in filters:
|
|
182
|
+
filters[key] = _many(filters[key])
|
|
183
|
+
return self._pages("/v2/statements", filters, max_items)
|
|
184
|
+
|
|
185
|
+
def statement(self, statement_id: str) -> dict:
|
|
186
|
+
return self.get(f"/v2/statements/{_segment(statement_id)}")
|
|
187
|
+
|
|
188
|
+
def feed(self, *, cursor: Optional[str] = None, symbol: Many = None, sector: Many = None,
|
|
189
|
+
theme_id: Optional[str] = None, topic_id: Optional[str] = None, sentiment: Many = None,
|
|
190
|
+
importance_min: Optional[str] = None, source_type: Many = None, limit: int = 100) -> dict:
|
|
191
|
+
"""Statements in the order they entered the API, after `cursor`. Without a cursor: the last 24 hours.
|
|
192
|
+
next_cursor is always set; store it and pass it on the next call."""
|
|
193
|
+
return self.get("/v2/feed", cursor=cursor, symbol=_many(symbol), sector=_many(sector), theme_id=theme_id,
|
|
194
|
+
topic_id=topic_id, sentiment=_many(sentiment), importance_min=importance_min,
|
|
195
|
+
source_type=_many(source_type), limit=limit)
|
|
196
|
+
|
|
197
|
+
def poll_feed(self, *, cursor: Optional[str] = None, cursor_file: Union[str, Path, None] = None,
|
|
198
|
+
interval: float = BUILD_INTERVAL_SECONDS, max_polls: Optional[int] = None,
|
|
199
|
+
**filters: Any) -> Iterator[dict]:
|
|
200
|
+
"""Yield new statements as they enter the API, forever (or for max_polls successful polls). Takes the
|
|
201
|
+
filters of feed().
|
|
202
|
+
|
|
203
|
+
With cursor_file the position survives restarts: a statement is recorded as handled when you ask for
|
|
204
|
+
the next one, so after a crash or a `break` nothing is skipped and only the statement you were working on
|
|
205
|
+
is delivered again (dedupe on `id` if that matters). Two limits:
|
|
206
|
+
- The first run starts 24 hours back, and there is no position to store until its first page has been
|
|
207
|
+
fully handled. A restart before that begins again 24 hours before the restart, so statements of that
|
|
208
|
+
first page that were not handled and are older than that are not delivered.
|
|
209
|
+
- After a long pause the poller reads the whole backlog since its saved position, page after page
|
|
210
|
+
without sleeping, one request per `limit` statements. Delete the file to start 24 hours back instead.
|
|
211
|
+
Without cursor_file every start begins 24 hours back. A saved position belongs to the filters it was
|
|
212
|
+
reached with: use one cursor file per set of filters.
|
|
213
|
+
|
|
214
|
+
A page that comes back full is followed immediately; otherwise the poller sleeps `interval` seconds, which
|
|
215
|
+
should not be lower than the API's 150 s build interval. Temporary failures (timeouts, network errors,
|
|
216
|
+
5xx) are retried with a growing pause of up to 5 minutes; other errors, such as a used-up quota (429),
|
|
217
|
+
are raised.
|
|
218
|
+
"""
|
|
219
|
+
path = Path(cursor_file) if cursor_file else None
|
|
220
|
+
handled: set = set()
|
|
221
|
+
if path is not None and not path.parent.is_dir():
|
|
222
|
+
raise ValueError(f"The directory of cursor_file does not exist: {path.parent}")
|
|
223
|
+
if path is not None and path.exists():
|
|
224
|
+
try:
|
|
225
|
+
state = json.loads(path.read_text())
|
|
226
|
+
saved, ids = state["cursor"], state.get("handled", [])
|
|
227
|
+
if not (saved is None or isinstance(saved, str)) or not isinstance(ids, list) \
|
|
228
|
+
or not all(isinstance(i, str) for i in ids):
|
|
229
|
+
raise TypeError
|
|
230
|
+
handled = set(ids)
|
|
231
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
232
|
+
raise ValueError(f"{path} is not a poll_feed cursor file; delete it to start 24 hours back") from exc
|
|
233
|
+
if cursor is None:
|
|
234
|
+
cursor = saved
|
|
235
|
+
else:
|
|
236
|
+
handled = set() # an explicit cursor overrides the file
|
|
237
|
+
|
|
238
|
+
def save(position: Optional[str], done: set) -> None:
|
|
239
|
+
if path is None:
|
|
240
|
+
return
|
|
241
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
242
|
+
tmp.write_text(json.dumps({"cursor": position, "handled": sorted(done)}))
|
|
243
|
+
os.replace(tmp, path) # never leaves a half-written file
|
|
244
|
+
|
|
245
|
+
save(cursor, handled) # fails now, not after the first statement, if the file cannot be written
|
|
246
|
+
limit = filters["limit"] = filters.get("limit") or 100
|
|
247
|
+
polls = failures = 0
|
|
248
|
+
while max_polls is None or polls < max_polls:
|
|
249
|
+
try:
|
|
250
|
+
page = self.feed(cursor=cursor, **filters)
|
|
251
|
+
except FinancePulseError as exc:
|
|
252
|
+
if not exc.transient:
|
|
253
|
+
raise
|
|
254
|
+
failures += 1
|
|
255
|
+
time.sleep(min(300, exc.retry_after or 10 * 2 ** min(failures - 1, 5)))
|
|
256
|
+
continue
|
|
257
|
+
failures = 0
|
|
258
|
+
polls += 1
|
|
259
|
+
for statement in page["data"]:
|
|
260
|
+
if statement["id"] in handled:
|
|
261
|
+
continue # delivered before the restart
|
|
262
|
+
yield statement
|
|
263
|
+
handled.add(statement["id"])
|
|
264
|
+
save(cursor, handled)
|
|
265
|
+
# The page is behind the new cursor; ids handled earlier that it did not contain (the previous run
|
|
266
|
+
# used a larger limit) are still ahead and stay recorded.
|
|
267
|
+
handled -= {statement["id"] for statement in page["data"]}
|
|
268
|
+
cursor = page["next_cursor"]
|
|
269
|
+
save(cursor, handled)
|
|
270
|
+
if len(page["data"]) < limit and (max_polls is None or polls < max_polls):
|
|
271
|
+
time.sleep(interval)
|
|
272
|
+
|
|
273
|
+
# ---- signals -----------------------------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def series(self, *, symbol: Optional[str] = None, sector: Optional[str] = None, theme_id: Optional[str] = None,
|
|
276
|
+
topic_id: Optional[str] = None, interval: str = "day", since: Optional[str] = None,
|
|
277
|
+
until: Optional[str] = None, source_type: Many = None, importance_min: Optional[str] = None) -> dict:
|
|
278
|
+
"""Hourly or daily statement counts, sentiment and distinct sources for exactly one entity (zero-filled).
|
|
279
|
+
The last bucket is the current, still incomplete hour or day."""
|
|
280
|
+
return self.get("/v2/series", symbol=symbol, sector=sector, theme_id=theme_id, topic_id=topic_id,
|
|
281
|
+
interval=interval, since=since, until=until, source_type=_many(source_type),
|
|
282
|
+
importance_min=importance_min)
|
|
283
|
+
|
|
284
|
+
def symbols(self, *, period: str = "w", sort: str = "mentions", sector: Optional[str] = None,
|
|
285
|
+
kind: Many = None, min_mentions: int = 3, limit: int = 500) -> dict:
|
|
286
|
+
"""Symbol screener. period: d (24 h), w (7 d), window. sort: mentions, sources, score, change.
|
|
287
|
+
kind narrows to entity kinds, e.g. ["equity", "etf"] for tradable tickers (all kinds are in meta()).
|
|
288
|
+
Each row carries the entity's `kind` and `name`. `change` is null for period="window". limit: up to
|
|
289
|
+
20000 (every symbol: period="window", min_mentions=1, limit=20000)."""
|
|
290
|
+
return self.get("/v2/symbols", period=period, sort=sort, sector=sector, kind=_many(kind),
|
|
291
|
+
min_mentions=min_mentions, limit=limit)
|
|
292
|
+
|
|
293
|
+
def sectors(self, *, period: str = "w", sort: str = "mentions") -> dict:
|
|
294
|
+
"""The 11 GICS sectors with the same statistics as symbols()."""
|
|
295
|
+
return self.get("/v2/sectors", period=period, sort=sort)
|
|
296
|
+
|
|
297
|
+
# ---- developments ------------------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def trending(self, *, kind: Optional[str] = None, theme_id: Optional[str] = None,
|
|
300
|
+
symbol: Optional[str] = None) -> dict:
|
|
301
|
+
"""Developments the most outlets picked up. kind: live (last 2 hours) or slot (2-hour slots, 7 days)."""
|
|
302
|
+
return self.get("/v2/trending", kind=kind, theme_id=theme_id, symbol=symbol)
|
|
303
|
+
|
|
304
|
+
def topics(self, *, theme_id: Optional[str] = None, symbol: Many = None, sector: Optional[str] = None,
|
|
305
|
+
ids: Many = None, updated_since: Optional[str] = None, min_statements: int = 1,
|
|
306
|
+
sort: str = "recent", limit: int = 50, cursor: Optional[str] = None) -> dict:
|
|
307
|
+
"""Topics (developments). sort: recent, size, oldest, velocity. ids: up to 50 topic ids.
|
|
308
|
+
A topic's `revision` changes whenever the topic or its last-7-day statements change."""
|
|
309
|
+
return self.get("/v2/topics", theme_id=theme_id, symbol=_many(symbol), sector=sector, ids=_many(ids),
|
|
310
|
+
updated_since=updated_since, min_statements=min_statements, sort=sort, limit=limit,
|
|
311
|
+
cursor=cursor)
|
|
312
|
+
|
|
313
|
+
def topic(self, topic_id: str) -> dict:
|
|
314
|
+
"""One topic with its statement counts per source type and its 20 newest statements."""
|
|
315
|
+
return self.get(f"/v2/topics/{_segment(topic_id)}")
|
|
316
|
+
|
|
317
|
+
def themes(self, *, symbol: Many = None, sector: Many = None, sort: str = "size") -> dict:
|
|
318
|
+
"""All themes with their statistics, or only those with statements about a symbol or sector.
|
|
319
|
+
sort: size (statements), topics, recent (last statement), velocity (last 24 h). Not paged."""
|
|
320
|
+
return self.get("/v2/themes", symbol=_many(symbol), sector=_many(sector), sort=sort)
|
|
321
|
+
|
|
322
|
+
def theme(self, theme_id: str) -> dict:
|
|
323
|
+
return self.get(f"/v2/themes/{_segment(theme_id)}")
|
|
324
|
+
|
|
325
|
+
def meta(self) -> dict:
|
|
326
|
+
"""The window, counts, known data incidents and every accepted filter value."""
|
|
327
|
+
return self.get("/v2/meta")
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""MCP server for Finance Pulse: lets an assistant query financial news as sourced statements.
|
|
2
|
+
|
|
3
|
+
Run: RAPIDAPI_KEY=... uvx finance-pulse (stdio transport; `finance-pulse-mcp` is the same command)
|
|
4
|
+
|
|
5
|
+
Every tool is one API request (plans have a monthly request quota), so results are trimmed to the fields an
|
|
6
|
+
assistant needs and default to small pages.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Annotated, Literal, Optional
|
|
14
|
+
|
|
15
|
+
from mcp.server.mcpserver import MCPServer
|
|
16
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
17
|
+
from mcp.types import ToolAnnotations
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .client import RAPIDAPI, FinancePulse, FinancePulseError
|
|
22
|
+
|
|
23
|
+
BACKEND = RAPIDAPI
|
|
24
|
+
|
|
25
|
+
Sector = Literal["CommunicationServices", "ConsumerDiscretionary", "ConsumerStaples", "Energy", "Financials",
|
|
26
|
+
"HealthCare", "Industrials", "InformationTechnology", "Materials", "RealEstate", "Utilities"]
|
|
27
|
+
Sentiment = Literal["bullish", "bearish", "neutral"]
|
|
28
|
+
Importance = Literal["low", "medium", "high", "ultra"]
|
|
29
|
+
SourceType = Literal["news", "speculative", "reddit"]
|
|
30
|
+
|
|
31
|
+
# Argument types: the description sits on the field, where the model reads it when it fills the call.
|
|
32
|
+
Symbols = Annotated[Optional[str], Field(description="Ticker or entity code, e.g. NVDA. Comma-separate several (OR), up to 10.")]
|
|
33
|
+
OneSymbol = Annotated[Optional[str], Field(description="One ticker or entity code, e.g. NVDA")]
|
|
34
|
+
SectorArg = Annotated[Optional[Sector], Field(description="GICS sector")]
|
|
35
|
+
ThemeId = Annotated[Optional[str], Field(description="Theme id from `themes` or `reference`")]
|
|
36
|
+
TopicId = Annotated[Optional[str], Field(description="Topic (development) id, as returned in topic_id or topics[].id")]
|
|
37
|
+
Time = Annotated[Optional[str], Field(description="ISO-8601 time, e.g. 2026-10-01T00:00:00Z")]
|
|
38
|
+
Cursor = Annotated[Optional[str], Field(description="next_cursor from the previous call, for the next page")]
|
|
39
|
+
PageSize = Annotated[int, Field(ge=1, le=100, description="How many to return, 1-100")]
|
|
40
|
+
|
|
41
|
+
INSTRUCTIONS = """Finance Pulse turns financial news into statements: one-sentence facts extracted from articles,
|
|
42
|
+
each with sentiment (bullish/bearish/neutral), importance (low/medium/high/ultra), symbols, GICS sectors, the
|
|
43
|
+
source and the article's publication time. Statements about the same development are clustered into topics, and
|
|
44
|
+
topics belong to themes. Data covers a rolling window of about two months and is rebuilt every few minutes.
|
|
45
|
+
|
|
46
|
+
Each tool call uses one request of the user's plan quota, so prefer one well-filtered call over several:
|
|
47
|
+
news about a company or sector -> search_statements; what is big right now -> trending; which names are getting
|
|
48
|
+
unusual attention -> screen; how coverage or tone changed over time -> sentiment_series; which broad subjects a
|
|
49
|
+
company or sector is in the news for -> themes.
|
|
50
|
+
Cite `source` and `published_at` when you quote a statement; `source_type` says whether it comes from news,
|
|
51
|
+
speculative analysis or reddit. Symbols are codes extracted by a language model: most equities match their
|
|
52
|
+
ticker, but macro entities are codes too (FED, ECB, CRUDE, US10Y). Sentiment describes the statement, not a
|
|
53
|
+
price forecast. Every result carries `lag_days`, the age of the newest statement in days: if it is well above
|
|
54
|
+
zero, news collection is behind, so say the data may be stale. Call `reference` when you need theme ids or want
|
|
55
|
+
to know about gaps in the data."""
|
|
56
|
+
|
|
57
|
+
server = MCPServer("finance-pulse", title="Finance Pulse", instructions=INSTRUCTIONS, version=__version__,
|
|
58
|
+
website_url="https://fintopic.news/docs")
|
|
59
|
+
|
|
60
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
|
|
61
|
+
|
|
62
|
+
_client: Optional[FinancePulse] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def client() -> FinancePulse:
|
|
66
|
+
global _client
|
|
67
|
+
if _client is None:
|
|
68
|
+
if not (os.environ.get(BACKEND.key_env) or "").strip():
|
|
69
|
+
raise ToolError(f"{BACKEND.key_env} is not set for the finance-pulse server. Add it to the server's "
|
|
70
|
+
f"env in the MCP client's configuration; get a free key at {BACKEND.signup_url}")
|
|
71
|
+
try:
|
|
72
|
+
_client = FinancePulse(backend=BACKEND)
|
|
73
|
+
except ValueError as exc: # a key that cannot be one
|
|
74
|
+
raise ToolError(str(exc)) from exc
|
|
75
|
+
return _client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _call(method: str, *args, **kwargs) -> dict:
|
|
79
|
+
"""One API request; errors become messages the assistant can act on (ToolError text reaches the model,
|
|
80
|
+
any other exception is reported only as "Error executing tool")."""
|
|
81
|
+
api = client()
|
|
82
|
+
try:
|
|
83
|
+
return getattr(api, method)(*args, **kwargs)
|
|
84
|
+
except FinancePulseError as exc:
|
|
85
|
+
if exc.status == 429:
|
|
86
|
+
raise ToolError(f"The plan's request quota or rate limit is used up ({exc.title}). "
|
|
87
|
+
f"Plans: {BACKEND.signup_url}") from exc
|
|
88
|
+
if exc.status in (401, 403):
|
|
89
|
+
raise ToolError(f"{BACKEND.key_env} is missing, wrong or not subscribed to Finance Pulse ({exc.title}). "
|
|
90
|
+
f"Subscribe (there is a free plan): {BACKEND.signup_url}") from exc
|
|
91
|
+
if exc.transient:
|
|
92
|
+
raise ToolError(f"Finance Pulse could not be reached or is temporarily unavailable ({exc}). "
|
|
93
|
+
f"Try again in a minute.") from exc
|
|
94
|
+
raise ToolError(str(exc)) from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _statement(s: dict) -> dict:
|
|
98
|
+
out = {"id": s["id"], "statement": s["statement"], "published_at": s["published_at"],
|
|
99
|
+
"sentiment": s["sentiment"], "importance": s["importance"], "source_type": s.get("source_type"),
|
|
100
|
+
"symbols": s.get("symbols") or [], "sectors": s.get("sectors") or [],
|
|
101
|
+
"source": s.get("source_domain") or "", "url": s.get("source_url"),
|
|
102
|
+
"topic_id": s.get("topic_id"), "topic": s.get("topic_name")}
|
|
103
|
+
if s.get("entities"): # what each symbol code stands for
|
|
104
|
+
out["entities"] = [{k: e.get(k) for k in ("code", "name", "kind")} for e in s["entities"]]
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _topic(t: dict) -> dict:
|
|
109
|
+
latest = t.get("latest_statement") or {}
|
|
110
|
+
out = {"id": t["id"], "name": t["name"], "theme_id": t.get("theme_id"), "updated_at": t.get("updated_at"),
|
|
111
|
+
"statements": t.get("statements"), "sources": t.get("sources"),
|
|
112
|
+
"statements_7d": t.get("statements_7d"), "statements_24h": t.get("statements_24h"),
|
|
113
|
+
"statements_4h": t.get("statements_4h"),
|
|
114
|
+
"sentiment": t.get("sentiment"), "symbols": (t.get("symbols") or [])[:8],
|
|
115
|
+
"latest_statement": latest.get("statement"), "latest_source": latest.get("source_domain")}
|
|
116
|
+
if "trending" in t:
|
|
117
|
+
out["trending"] = t["trending"]
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fresh(response: dict) -> dict:
|
|
122
|
+
snapshot = response.get("snapshot") or {}
|
|
123
|
+
return {"built_at": snapshot.get("built_at"), "window_start": snapshot.get("window_start"),
|
|
124
|
+
"lag_days": snapshot.get("lag_days")}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@server.tool(title="Search news statements", annotations=READ_ONLY)
|
|
128
|
+
def search_statements(
|
|
129
|
+
symbol: Symbols = None, sector: SectorArg = None, theme_id: ThemeId = None, topic_id: TopicId = None,
|
|
130
|
+
sentiment: Annotated[Optional[Sentiment], Field(description="Only statements with this sentiment")] = None,
|
|
131
|
+
importance_min: Annotated[Optional[Importance], Field(description="Minimum importance; high = key facts only")] = None,
|
|
132
|
+
source_type: Annotated[Optional[SourceType], Field(description="news leaves out speculative analysis and reddit")] = None,
|
|
133
|
+
since: Time = None, until: Time = None, limit: PageSize = 15, cursor: Cursor = None) -> dict:
|
|
134
|
+
"""Search extracted news statements, newest first. Use for "what is the news saying about X".
|
|
135
|
+
|
|
136
|
+
symbol: ticker or entity code, e.g. NVDA; comma-separate several (OR), no spaces inside a code.
|
|
137
|
+
importance_min=high keeps only key facts. source_type=news leaves out speculative analysis and reddit.
|
|
138
|
+
since/until: ISO-8601 times on the article's publication time, e.g. 2026-10-01T00:00:00Z.
|
|
139
|
+
limit: 1-100. Pass next_cursor back as cursor for the next page (null means no more).
|
|
140
|
+
"""
|
|
141
|
+
r = _call("statements", symbol=symbol, sector=sector, theme_id=theme_id, topic_id=topic_id, sentiment=sentiment,
|
|
142
|
+
importance_min=importance_min, source_type=source_type, since=since, until=until, limit=limit,
|
|
143
|
+
cursor=cursor)
|
|
144
|
+
return {"statements": [_statement(s) for s in r["data"]], "next_cursor": r.get("next_cursor"), **_fresh(r)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@server.tool(title="Trending developments", annotations=READ_ONLY)
|
|
148
|
+
def trending(
|
|
149
|
+
kind: Annotated[Literal["live", "slot"], Field(description="live = last 2 hours; slot = 2-hour slots over 7 days")] = "live",
|
|
150
|
+
symbol: OneSymbol = None, theme_id: ThemeId = None,
|
|
151
|
+
limit: Annotated[int, Field(ge=1, le=300, description="How many to return, taken from the top of the API's ranking; `total` in the result says how many there are")] = 15) -> dict:
|
|
152
|
+
"""The developments most outlets are reporting. Use for "what is moving / what is the big news right now".
|
|
153
|
+
|
|
154
|
+
kind=live: the last 2 hours; kind=slot: the top stories of each 2-hour slot over the last 7 days.
|
|
155
|
+
For the whole day rather than the last 2 hours, use find_topics with sort=velocity (last 24 hours).
|
|
156
|
+
kind=slot lists up to 10 stories per slot over 7 days, so raise limit when you need more than the top of it;
|
|
157
|
+
each item's `trending` labels say which slots it was in and at what rank.
|
|
158
|
+
Each item has its distinct source count, sentiment counts and newest statement. symbol: one code only.
|
|
159
|
+
"""
|
|
160
|
+
r = _call("trending", kind=kind, symbol=symbol, theme_id=theme_id)
|
|
161
|
+
return {"topics": [_topic(t) for t in r["data"][:limit]], "total": len(r["data"]), **_fresh(r)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@server.tool(title="Get one development", annotations=READ_ONLY)
|
|
165
|
+
def get_topic(topic_id: Annotated[str, Field(description="Topic (development) id, as returned in topic_id or topics[].id")]) -> dict:
|
|
166
|
+
"""One development in full: its counts, how many of its statements come from news, speculative analysis and
|
|
167
|
+
reddit (source_mix), and its 20 newest statements, each with its outlet. For older statements call
|
|
168
|
+
search_statements with topic_id and the returned statements_cursor as cursor."""
|
|
169
|
+
r = _call("topic", topic_id)
|
|
170
|
+
t = r["data"]
|
|
171
|
+
return {**_topic(t), "created_at": t.get("created_at"), "source_mix": t.get("source_mix"),
|
|
172
|
+
"statements_page": [_statement(s) for s in t.get("statements_page") or []],
|
|
173
|
+
"statements_cursor": t.get("statements_cursor"), **_fresh(r)}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@server.tool(title="Find developments", annotations=READ_ONLY)
|
|
177
|
+
def find_topics(
|
|
178
|
+
symbol: Symbols = None, sector: SectorArg = None, theme_id: ThemeId = None,
|
|
179
|
+
sort: Literal["recent", "size", "velocity", "oldest"] = "velocity",
|
|
180
|
+
min_statements: Annotated[int, Field(ge=1, description="Leave out developments with fewer statements")] = 3,
|
|
181
|
+
limit: PageSize = 15, cursor: Cursor = None) -> dict:
|
|
182
|
+
"""List developments for a symbol, sector or theme. sort=velocity: most statements in the last 24 hours;
|
|
183
|
+
size: most statements in the window; recent: latest statement first. symbol: comma-separate several (OR)."""
|
|
184
|
+
r = _call("topics", symbol=symbol, sector=sector, theme_id=theme_id, sort=sort, min_statements=min_statements,
|
|
185
|
+
limit=limit, cursor=cursor)
|
|
186
|
+
return {"topics": [_topic(t) for t in r["data"]], "next_cursor": r.get("next_cursor"), **_fresh(r)}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@server.tool(title="Themes", annotations=READ_ONLY)
|
|
190
|
+
def themes(symbol: Symbols = None, sector: SectorArg = None,
|
|
191
|
+
sort: Annotated[Literal["size", "topics", "recent", "velocity"], Field(description="size = statements; topics = developments; recent = last statement; velocity = statements in the last 24 hours")] = "size") -> dict:
|
|
192
|
+
"""The standing subjects the news is grouped into (Energy, Central banks & rates, ...), each with its number
|
|
193
|
+
of developments and statements, 24-hour volume, distinct sources and sentiment counts. With symbol or sector:
|
|
194
|
+
only the themes that have statements about it; the counts are still those of the whole theme, so use
|
|
195
|
+
search_statements with theme_id and symbol for the symbol's own statements. Use the returned id as theme_id
|
|
196
|
+
in the other tools."""
|
|
197
|
+
r = _call("themes", symbol=symbol, sector=sector, sort=sort)
|
|
198
|
+
keep = ("id", "name", "topics", "statements", "statements_24h", "statements_4h", "sources", "sentiment",
|
|
199
|
+
"sentiment_score", "updated_at")
|
|
200
|
+
return {"themes": [{k: t.get(k) for k in keep} for t in r["data"]], **_fresh(r)}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@server.tool(title="News volume and sentiment over time", annotations=READ_ONLY)
|
|
204
|
+
def sentiment_series(symbol: OneSymbol = None, sector: SectorArg = None, theme_id: ThemeId = None,
|
|
205
|
+
topic_id: TopicId = None, interval: Literal["day", "hour"] = "day",
|
|
206
|
+
since: Time = None, until: Time = None) -> dict:
|
|
207
|
+
"""News volume and sentiment over time for exactly one of symbol, sector, theme_id or topic_id.
|
|
208
|
+
|
|
209
|
+
Each bucket: t (UTC start), count, bullish, bearish, neutral, sources (distinct outlets) and
|
|
210
|
+
score = (bullish - bearish) / count. Buckets with no statements are omitted here. Unless `until` is set, the
|
|
211
|
+
series ends at the current, still incomplete day or hour: do not read a low last bucket as a drop. interval=hour covers at most 14 days and
|
|
212
|
+
defaults to the last 7; interval=day defaults to the whole window.
|
|
213
|
+
"""
|
|
214
|
+
if sum(bool(v) for v in (symbol, sector, theme_id, topic_id)) != 1:
|
|
215
|
+
raise ToolError("Pass exactly one of symbol, sector, theme_id, topic_id")
|
|
216
|
+
r = _call("series", symbol=symbol, sector=sector, theme_id=theme_id, topic_id=topic_id, interval=interval,
|
|
217
|
+
since=since, until=until)
|
|
218
|
+
buckets = [{k: b.get(k) for k in ("t", "count", "bullish", "bearish", "neutral", "sources", "score")}
|
|
219
|
+
for b in r["data"]["buckets"] if b.get("count")]
|
|
220
|
+
return {"entity": r["data"]["entity"], "interval": interval, "buckets": buckets,
|
|
221
|
+
"empty_buckets_omitted": len(r["data"]["buckets"]) - len(buckets), **_fresh(r)}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@server.tool(title="Screen symbols or sectors by news attention", annotations=READ_ONLY)
|
|
225
|
+
def screen(
|
|
226
|
+
rank: Literal["symbols", "sectors"] = "symbols",
|
|
227
|
+
period: Annotated[Literal["d", "w", "window"], Field(description="d = 24 hours, w = 7 days, window = everything held")] = "d",
|
|
228
|
+
sort: Literal["change", "mentions", "sources", "score"] = "change", sector: SectorArg = None,
|
|
229
|
+
entity_kinds: Annotated[Optional[str], Field(description='Comma-separated entity kinds, e.g. "equity,etf"')] = None,
|
|
230
|
+
min_mentions: Annotated[int, Field(ge=1, description="Leave out codes with fewer mentions")] = 3,
|
|
231
|
+
limit: Annotated[int, Field(ge=1, le=500, description="How many rows to return")] = 20) -> dict:
|
|
232
|
+
"""Rank symbols (or the 11 GICS sectors) by news attention. Use for "which tickers are suddenly in the news".
|
|
233
|
+
|
|
234
|
+
period: d = last 24 hours, w = last 7 days, window = everything held. sort=change ranks by the rise in
|
|
235
|
+
mentions against the previous period (not available for period=window; use sort=mentions there).
|
|
236
|
+
Each row: mentions, topics, sources (distinct outlets), sentiment counts, score and change.
|
|
237
|
+
Only for rank=symbols: sector limits the screen to one sector; min_mentions drops rarely mentioned codes;
|
|
238
|
+
entity_kinds narrows the kind of entity, comma-separated, from: equity, etf, index, rate, fx, crypto,
|
|
239
|
+
commodity, org, private, country. Without it the list mixes companies with rates, commodities and central
|
|
240
|
+
banks; pass "equity,etf" for tradable tickers.
|
|
241
|
+
"""
|
|
242
|
+
if rank == "sectors":
|
|
243
|
+
if sector or entity_kinds:
|
|
244
|
+
raise ToolError("sector and entity_kinds only apply to rank=symbols")
|
|
245
|
+
r = _call("sectors", period=period, sort=sort)
|
|
246
|
+
else:
|
|
247
|
+
r = _call("symbols", period=period, sort=sort, sector=sector, kind=entity_kinds, min_mentions=min_mentions,
|
|
248
|
+
limit=limit)
|
|
249
|
+
return {"rank": rank, "period": period, "rows": r["data"][:limit], **_fresh(r)}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@server.tool(title="Data window, themes and accepted values", annotations=READ_ONLY)
|
|
253
|
+
def reference() -> dict:
|
|
254
|
+
"""The data's window and size, known data incidents (outages, delays), the themes with their ids, and the
|
|
255
|
+
accepted sector, sentiment, importance, source-type and entity-kind values. Call once; it rarely changes."""
|
|
256
|
+
r = _call("meta")
|
|
257
|
+
return {**r["data"], **_fresh(r)}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main() -> None:
|
|
261
|
+
if sys.stdin.isatty():
|
|
262
|
+
print("finance-pulse is an MCP server: it talks to an MCP client (Claude, Cursor, ...) over stdin/stdout.\n"
|
|
263
|
+
"Setup: https://github.com/SlothyAfk/finance-pulse-python#readme", file=sys.stderr)
|
|
264
|
+
sys.exit(2) # no MCP client starts a server with a terminal as its stdin
|
|
265
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # one INFO line per request otherwise fills client logs
|
|
266
|
+
server.run()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
main()
|
finance_pulse/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: finance-pulse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client and MCP server for the Finance Pulse API: financial news as structured, sourced statements with sentiment, clustered into developments.
|
|
5
|
+
Project-URL: Homepage, https://fintopic.news/docs
|
|
6
|
+
Project-URL: Documentation, https://fintopic.news/docs/reference
|
|
7
|
+
Project-URL: Get an API key, https://rapidapi.com/evankos/api/finance-pulse/pricing
|
|
8
|
+
Project-URL: Repository, https://github.com/SlothyAfk/finance-pulse-python
|
|
9
|
+
Project-URL: Issues, https://github.com/SlothyAfk/finance-pulse-python/issues
|
|
10
|
+
Author: FinTopic
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: finance,llm,mcp,news,rag,rapidapi,sentiment,stocks
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: mcp<3,>=2
|
|
28
|
+
Requires-Dist: pydantic>=2
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# Finance Pulse: Python client and MCP server
|
|
34
|
+
|
|
35
|
+
<!-- mcp-name: io.github.SlothyAfk/finance-pulse -->
|
|
36
|
+
|
|
37
|
+
Financial news as structured, sourced data. [Finance Pulse](https://fintopic.news/docs) reads financial news as it
|
|
38
|
+
is published and turns every article into **statements**: one-sentence facts with sentiment, importance, tickers,
|
|
39
|
+
sector, source and publication time. Statements about the same development are clustered into **topics**, and
|
|
40
|
+
topics into 19 **themes**. [fintopic.news](https://fintopic.news/) is a front page built on the same API.
|
|
41
|
+
|
|
42
|
+
This package gives you two ways to use it:
|
|
43
|
+
|
|
44
|
+
- a **Python client** for scripts, notebooks and pipelines;
|
|
45
|
+
- an **MCP server**, so Claude, Cursor and other MCP clients can query the news directly.
|
|
46
|
+
|
|
47
|
+
**You need a RapidAPI key that is subscribed to Finance Pulse.** Create a RapidAPI account, open the
|
|
48
|
+
[plans page](https://rapidapi.com/evankos/api/finance-pulse/pricing) and subscribe to a plan: Basic is free and is
|
|
49
|
+
enough to try everything below. Then copy your `X-RapidAPI-Key` from the API's page on RapidAPI (the endpoint playground shows it). A key that
|
|
50
|
+
is not subscribed to a plan is answered with 403.
|
|
51
|
+
|
|
52
|
+
## Use it from Claude or Cursor (MCP)
|
|
53
|
+
|
|
54
|
+
The server runs with `uvx`, which is part of [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
55
|
+
install uv first. The package itself needs no separate install.
|
|
56
|
+
|
|
57
|
+
**Claude Code**
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
claude mcp add --env RAPIDAPI_KEY=YOUR_RAPIDAPI_KEY --transport stdio finance-pulse -- uvx finance-pulse
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Claude Desktop** and **Cursor**
|
|
64
|
+
|
|
65
|
+
- Claude Desktop: Settings > Developer > Edit Config opens `claude_desktop_config.json`
|
|
66
|
+
(macOS `~/Library/Application Support/Claude/`, Windows `%APPDATA%\Claude\`). Quit and restart Claude Desktop
|
|
67
|
+
completely afterwards.
|
|
68
|
+
- Cursor: `~/.cursor/mcp.json`
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"finance-pulse": {
|
|
74
|
+
"command": "uvx",
|
|
75
|
+
"args": ["finance-pulse"],
|
|
76
|
+
"env": { "RAPIDAPI_KEY": "YOUR_RAPIDAPI_KEY" }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If the server does not start in Claude Desktop, it usually cannot find `uvx`: put the full path (the output of
|
|
83
|
+
`which uvx`, or `where uvx` on Windows) in `"command"`.
|
|
84
|
+
|
|
85
|
+
`uvx` keeps the version it installed first. To move to a new release, use `finance-pulse@latest` in place of
|
|
86
|
+
`finance-pulse` once, or run `uv cache clean finance-pulse`.
|
|
87
|
+
|
|
88
|
+
Then ask things like:
|
|
89
|
+
|
|
90
|
+
- "What is the news saying about Nvidia today? Only the key facts, with sources."
|
|
91
|
+
- "Which tickers are suddenly getting more coverage than yesterday?"
|
|
92
|
+
- "What are the top developing stories in Energy, and how did the biggest one grow?"
|
|
93
|
+
- "Show me Tesla's daily news sentiment for the last two weeks."
|
|
94
|
+
|
|
95
|
+
### Tools
|
|
96
|
+
|
|
97
|
+
| Tool | What it does |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `search_statements` | Statements by symbol, sector, theme, topic, sentiment, minimum importance and time range |
|
|
100
|
+
| `trending` | The developments most outlets are reporting now, or per 2-hour slot over 7 days |
|
|
101
|
+
| `find_topics` | Developments for a symbol, sector or theme, by velocity, size or recency |
|
|
102
|
+
| `get_topic` | One development: counts, its mix of news, analysis and reddit statements, newest statements with their outlets |
|
|
103
|
+
| `sentiment_series` | Daily or hourly news volume and sentiment for a symbol, sector, theme or topic |
|
|
104
|
+
| `screen` | Symbols (optionally only equities and ETFs) or sectors ranked by news attention and its change against the previous period |
|
|
105
|
+
| `themes` | The standing subjects with their volume and sentiment, optionally only those a symbol or sector appears in |
|
|
106
|
+
| `reference` | The data window, known data incidents, theme ids and accepted filter values |
|
|
107
|
+
|
|
108
|
+
Each tool call is one API request. Results are trimmed and default to small pages, so the free plan goes a long way.
|
|
109
|
+
|
|
110
|
+
## Use it from Python
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install finance-pulse
|
|
114
|
+
export RAPIDAPI_KEY=YOUR_RAPIDAPI_KEY
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The MCP SDK is installed with the package even if you only use the client.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from datetime import datetime, timedelta, timezone
|
|
121
|
+
|
|
122
|
+
from finance_pulse import FinancePulse
|
|
123
|
+
|
|
124
|
+
fp = FinancePulse() # or FinancePulse("YOUR_RAPIDAPI_KEY")
|
|
125
|
+
|
|
126
|
+
# The newest key facts about a ticker
|
|
127
|
+
for s in fp.statements(symbol="NVDA", importance_min="high", limit=5)["data"]:
|
|
128
|
+
print(s["published_at"][:16], s["sentiment"], s["statement"], f"({s['source_domain']})")
|
|
129
|
+
|
|
130
|
+
# Which tickers are suddenly in the news (kind= leaves out rates, central banks, countries, ...)
|
|
131
|
+
for row in fp.symbols(period="d", sort="change", kind=["equity", "etf"], limit=10)["data"]:
|
|
132
|
+
print(row["id"], row["mentions"], row["change"])
|
|
133
|
+
|
|
134
|
+
# Daily news volume and sentiment, last two weeks
|
|
135
|
+
since = (datetime.now(timezone.utc) - timedelta(days=14)).strftime("%Y-%m-%dT00:00:00Z")
|
|
136
|
+
for b in fp.series(symbol="TSLA", interval="day", since=since)["data"]["buckets"]:
|
|
137
|
+
print(b["t"][:10], b["count"], b["score"])
|
|
138
|
+
|
|
139
|
+
# What most outlets are reporting right now
|
|
140
|
+
for t in fp.trending(kind="live")["data"][:10]:
|
|
141
|
+
print(t["sources"], "sources:", t["name"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Every method returns the API's JSON unchanged: `{"data": ..., "next_cursor": ..., "snapshot": {...}}`. The
|
|
145
|
+
`snapshot` block says how fresh the data is.
|
|
146
|
+
|
|
147
|
+
### Follow the news without missing anything
|
|
148
|
+
|
|
149
|
+
`poll_feed` yields every new statement in the order it entered the API and keeps its position in a file, so it
|
|
150
|
+
survives restarts: after a crash nothing is skipped and only the statement you were working on is delivered
|
|
151
|
+
again. The first run, with no saved position, starts 24 hours back, and the position is first stored once that
|
|
152
|
+
first page has been handled. After a long pause the poller reads the whole backlog since its saved position, one
|
|
153
|
+
request per 100 statements; delete the cursor file to start 24 hours back instead.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
for s in fp.poll_feed(sector="Energy", importance_min="high", cursor_file="energy.cursor.json"):
|
|
157
|
+
print(s["indexed_at"], s["statement"], s["source_url"])
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The API builds new data about every 2.5 minutes, so the poller waits 150 seconds between polls by default. One
|
|
161
|
+
poller at that rate makes about 576 requests a day. Timeouts and server errors are retried; a used-up quota (429)
|
|
162
|
+
is raised.
|
|
163
|
+
|
|
164
|
+
### More than one page
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
rows = list(fp.iter_statements(symbol=["NVDA", "AMD"], importance_min="medium", max_items=1000))
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Each page of 100 is one request. Without `max_items` it stops after 500 rows; `max_items=None` reads to the end
|
|
171
|
+
of the window.
|
|
172
|
+
|
|
173
|
+
### Errors
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from finance_pulse import FinancePulseError
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
fp.statements(symbol="S&P")
|
|
180
|
+
except FinancePulseError as e:
|
|
181
|
+
print(e.status, e.title, e.detail, e.param)
|
|
182
|
+
# 400 | Invalid symbol | 'S&P' is not a ticker | symbol
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A `429` means the plan's request quota or rate limit is used up. Timeouts and network failures are raised as
|
|
186
|
+
`FinancePulseError` too, with `status` 0; `e.transient` is true for those and for 5xx answers.
|
|
187
|
+
|
|
188
|
+
### Methods
|
|
189
|
+
|
|
190
|
+
| Method | Endpoint |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `statements`, `iter_statements`, `statement` | `/v2/statements`, `/v2/statements/{id}` |
|
|
193
|
+
| `feed`, `poll_feed` | `/v2/feed` |
|
|
194
|
+
| `series` | `/v2/series` |
|
|
195
|
+
| `symbols`, `sectors` | `/v2/symbols`, `/v2/sectors` |
|
|
196
|
+
| `trending` | `/v2/trending` |
|
|
197
|
+
| `topics`, `topic` | `/v2/topics`, `/v2/topics/{id}` |
|
|
198
|
+
| `themes`, `theme` | `/v2/themes`, `/v2/themes/{id}` |
|
|
199
|
+
| `meta` | `/v2/meta` |
|
|
200
|
+
|
|
201
|
+
The package targets Finance Pulse API 2.0 (the `/v2` endpoints). Parameters and response fields are documented in
|
|
202
|
+
the [API reference](https://fintopic.news/docs/reference), and the
|
|
203
|
+
[guides](https://fintopic.news/docs) show complete worked examples.
|
|
204
|
+
|
|
205
|
+
## What the data is, and is not
|
|
206
|
+
|
|
207
|
+
- **Symbols are codes extracted by a language model**, not exchange tickers. Most equities match their ticker;
|
|
208
|
+
macro entities are codes too (`FED`, `ECB`, `CRUDE`, `US10Y`). Map the codes you trade before relying on them.
|
|
209
|
+
- **Sentiment describes the statement**, not a price forecast. A symbol's sentiment is the count of its
|
|
210
|
+
statements' labels.
|
|
211
|
+
- **The window is a rolling 62 days.** `meta()` lists known outages and delays under `incidents`.
|
|
212
|
+
|
|
213
|
+
See [the docs](https://fintopic.news/docs) for the full list of caveats.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pip install -e ".[dev]"
|
|
219
|
+
pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The tests use canned responses and make no API requests.
|
|
223
|
+
|
|
224
|
+
### Releasing
|
|
225
|
+
|
|
226
|
+
1. Set the new version in `src/finance_pulse/__init__.py` and in `server.json` (two places).
|
|
227
|
+
2. Build and upload to PyPI (`uv build && uv publish`). From GitHub Actions, use `pypa/gh-action-pypi-publish`
|
|
228
|
+
v1.14.2 or newer: older versions reject the metadata version this build writes.
|
|
229
|
+
3. Only then run `mcp-publisher publish`: the registry verifies the package on PyPI (it looks for the
|
|
230
|
+
`mcp-name` line in its description), so the release must be there first.
|
|
231
|
+
|
|
232
|
+
## Licence
|
|
233
|
+
|
|
234
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
finance_pulse/__init__.py,sha256=bHyDS9PDbvMNYsMA4IvfPYJvSKXO1iWouUGAShjC4QI,265
|
|
2
|
+
finance_pulse/client.py,sha256=i-8nQeSI67PZK23uXXFpYbFWmSi9dRV6TBQb--6YCKI,18510
|
|
3
|
+
finance_pulse/mcp_server.py,sha256=q524TpGDU3cjlEqeZnP9i0Q4Ku_cbVil5aAz8F0IIYI,17010
|
|
4
|
+
finance_pulse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
finance_pulse-0.1.0.dist-info/METADATA,sha256=GnuvkZnbsk1J4oSmqZJluylaCTwb553JopFHlqxCN8U,10030
|
|
6
|
+
finance_pulse-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
finance_pulse-0.1.0.dist-info/entry_points.txt,sha256=SHJwhQXrKvhBFdUif1h7p9eIxmAY-I3yQmXQqamhOn8,114
|
|
8
|
+
finance_pulse-0.1.0.dist-info/licenses/LICENSE,sha256=OwopmkKt7yS4Gl0Z7OF0Git4oqP0YPNEFm0BaSdSai0,1065
|
|
9
|
+
finance_pulse-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FinTopic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|