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.
@@ -0,0 +1,5 @@
1
+ """Finance Pulse: financial news as structured, sourced statements (https://fintopic.news/docs)."""
2
+ from .client import RAPIDAPI, Backend, FinancePulse, FinancePulseError
3
+
4
+ __all__ = ["FinancePulse", "FinancePulseError", "Backend", "RAPIDAPI"]
5
+ __version__ = "0.1.0"
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ finance-pulse = finance_pulse.mcp_server:main
3
+ finance-pulse-mcp = finance_pulse.mcp_server:main
@@ -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.