finbrain-python 0.2.4__py3-none-any.whl → 0.2.5__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.
- finbrain/aio/client.py +5 -3
- finbrain/aio/endpoints/_utils.py +4 -29
- finbrain/aio/endpoints/patent_filings.py +48 -0
- finbrain/aio/endpoints/screener.py +12 -0
- finbrain/client.py +18 -3
- finbrain/endpoints/_utils.py +7 -1
- finbrain/endpoints/patent_filings.py +74 -0
- finbrain/endpoints/screener.py +12 -0
- finbrain/exceptions.py +24 -0
- finbrain/plotting.py +381 -164
- {finbrain_python-0.2.4.dist-info → finbrain_python-0.2.5.dist-info}/METADATA +28 -1
- {finbrain_python-0.2.4.dist-info → finbrain_python-0.2.5.dist-info}/RECORD +15 -13
- {finbrain_python-0.2.4.dist-info → finbrain_python-0.2.5.dist-info}/WHEEL +0 -0
- {finbrain_python-0.2.4.dist-info → finbrain_python-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {finbrain_python-0.2.4.dist-info → finbrain_python-0.2.5.dist-info}/top_level.txt +0 -0
finbrain/aio/client.py
CHANGED
|
@@ -25,10 +25,11 @@ from .endpoints.recent import AsyncRecentAPI
|
|
|
25
25
|
from .endpoints.corporate_lobbying import AsyncCorporateLobbyingAPI
|
|
26
26
|
from .endpoints.reddit_mentions import AsyncRedditMentionsAPI
|
|
27
27
|
from .endpoints.government_contracts import AsyncGovernmentContractsAPI
|
|
28
|
+
from .endpoints.patent_filings import AsyncPatentFilingsAPI
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
# Which status codes merit a retry
|
|
31
|
-
_RETRYABLE_STATUS = {500}
|
|
31
|
+
# Which status codes merit a retry (transient server / gateway errors)
|
|
32
|
+
_RETRYABLE_STATUS = {500, 502, 503, 504}
|
|
32
33
|
# How long to wait between retries (2, 4, 8 … seconds)
|
|
33
34
|
_BACKOFF_BASE = 2
|
|
34
35
|
|
|
@@ -86,6 +87,7 @@ class AsyncFinBrainClient:
|
|
|
86
87
|
self.corporate_lobbying = AsyncCorporateLobbyingAPI(self)
|
|
87
88
|
self.reddit_mentions = AsyncRedditMentionsAPI(self)
|
|
88
89
|
self.government_contracts = AsyncGovernmentContractsAPI(self)
|
|
90
|
+
self.patent_filings = AsyncPatentFilingsAPI(self)
|
|
89
91
|
|
|
90
92
|
async def __aenter__(self) -> "AsyncFinBrainClient":
|
|
91
93
|
"""Context manager entry."""
|
|
@@ -164,7 +166,7 @@ class AsyncFinBrainClient:
|
|
|
164
166
|
|
|
165
167
|
# ── Error path ───────────────────────────────────
|
|
166
168
|
if resp.status_code in _RETRYABLE_STATUS and attempt < self.retries:
|
|
167
|
-
#
|
|
169
|
+
# Transient server/gateway error – exponential back-off then retry
|
|
168
170
|
await asyncio.sleep(_BACKOFF_BASE**attempt)
|
|
169
171
|
continue
|
|
170
172
|
|
finbrain/aio/endpoints/_utils.py
CHANGED
|
@@ -1,37 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Shared utility functions for async endpoint modules.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
These helpers are identical to the synchronous ones, so they are re-exported
|
|
5
|
+
from :mod:`finbrain.endpoints._utils` to keep a single source of truth.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
|
-
import datetime as _dt
|
|
10
9
|
|
|
10
|
+
from ...endpoints._utils import to_datestr
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
"""
|
|
14
|
-
Convert datetime.date to ISO format string (YYYY-MM-DD).
|
|
15
|
-
|
|
16
|
-
If the input is already a string, it is returned unchanged.
|
|
17
|
-
This allows flexible date parameter handling in API calls.
|
|
18
|
-
|
|
19
|
-
Parameters
|
|
20
|
-
----------
|
|
21
|
-
value : datetime.date or str
|
|
22
|
-
Date value to convert.
|
|
23
|
-
|
|
24
|
-
Returns
|
|
25
|
-
-------
|
|
26
|
-
str
|
|
27
|
-
ISO format date string (YYYY-MM-DD).
|
|
28
|
-
|
|
29
|
-
Examples
|
|
30
|
-
--------
|
|
31
|
-
>>> from datetime import date
|
|
32
|
-
>>> to_datestr(date(2025, 1, 15))
|
|
33
|
-
'2025-01-15'
|
|
34
|
-
>>> to_datestr("2025-01-15")
|
|
35
|
-
'2025-01-15'
|
|
36
|
-
"""
|
|
37
|
-
return value.isoformat() if isinstance(value, _dt.date) else value
|
|
12
|
+
__all__ = ["to_datestr"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, Any, List
|
|
5
|
+
|
|
6
|
+
from ._utils import to_datestr
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..client import AsyncFinBrainClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncPatentFilingsAPI:
|
|
13
|
+
"""Async wrapper for /patent-filings and /screener/patent-filings endpoints."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: "AsyncFinBrainClient") -> None:
|
|
16
|
+
self._c = client
|
|
17
|
+
|
|
18
|
+
async def ticker(
|
|
19
|
+
self,
|
|
20
|
+
symbol: str,
|
|
21
|
+
*,
|
|
22
|
+
date_from: _dt.date | str | None = None,
|
|
23
|
+
date_to: _dt.date | str | None = None,
|
|
24
|
+
limit: int | None = None,
|
|
25
|
+
as_dataframe: bool = False,
|
|
26
|
+
) -> Dict[str, Any] | pd.DataFrame:
|
|
27
|
+
"""Fetch USPTO granted patents for a symbol (async)."""
|
|
28
|
+
params: Dict[str, str] = {}
|
|
29
|
+
if date_from:
|
|
30
|
+
params["startDate"] = to_datestr(date_from)
|
|
31
|
+
if date_to:
|
|
32
|
+
params["endDate"] = to_datestr(date_to)
|
|
33
|
+
if limit is not None:
|
|
34
|
+
params["limit"] = str(limit)
|
|
35
|
+
|
|
36
|
+
path = f"patent-filings/{symbol.upper()}"
|
|
37
|
+
|
|
38
|
+
data: Dict[str, Any] = await self._c._request("GET", path, params=params)
|
|
39
|
+
|
|
40
|
+
if as_dataframe:
|
|
41
|
+
rows: List[Dict[str, Any]] = data.get("patents", [])
|
|
42
|
+
df = pd.DataFrame(rows)
|
|
43
|
+
if not df.empty and "patentDate" in df.columns:
|
|
44
|
+
df["patentDate"] = pd.to_datetime(df["patentDate"])
|
|
45
|
+
df.set_index("patentDate", inplace=True)
|
|
46
|
+
return df
|
|
47
|
+
|
|
48
|
+
return data
|
|
@@ -234,3 +234,15 @@ class AsyncScreenerAPI:
|
|
|
234
234
|
"""Screen government contracts across all tickers (async)."""
|
|
235
235
|
params = self._build_params(limit=limit)
|
|
236
236
|
return await self._get("screener/government-contracts", params, as_dataframe)
|
|
237
|
+
|
|
238
|
+
# ── patent filings ────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
async def patent_filings(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
limit: int | None = None,
|
|
244
|
+
as_dataframe: bool = False,
|
|
245
|
+
) -> List[Dict[str, Any]] | pd.DataFrame:
|
|
246
|
+
"""Screen USPTO patent filings across all tickers (async)."""
|
|
247
|
+
params = self._build_params(limit=limit)
|
|
248
|
+
return await self._get("screener/patent-filings", params, as_dataframe)
|
finbrain/client.py
CHANGED
|
@@ -26,10 +26,11 @@ from .endpoints.recent import RecentAPI
|
|
|
26
26
|
from .endpoints.corporate_lobbying import CorporateLobbyingAPI
|
|
27
27
|
from .endpoints.reddit_mentions import RedditMentionsAPI
|
|
28
28
|
from .endpoints.government_contracts import GovernmentContractsAPI
|
|
29
|
+
from .endpoints.patent_filings import PatentFilingsAPI
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
# Which status codes merit a retry
|
|
32
|
-
_RETRYABLE_STATUS = {500}
|
|
32
|
+
# Which status codes merit a retry (transient server / gateway errors)
|
|
33
|
+
_RETRYABLE_STATUS = {500, 502, 503, 504}
|
|
33
34
|
# How long to wait between retries (2, 4, 8 … seconds)
|
|
34
35
|
_BACKOFF_BASE = 2
|
|
35
36
|
|
|
@@ -81,6 +82,20 @@ class FinBrainClient:
|
|
|
81
82
|
self.corporate_lobbying = CorporateLobbyingAPI(self)
|
|
82
83
|
self.reddit_mentions = RedditMentionsAPI(self)
|
|
83
84
|
self.government_contracts = GovernmentContractsAPI(self)
|
|
85
|
+
self.patent_filings = PatentFilingsAPI(self)
|
|
86
|
+
|
|
87
|
+
# ---------- lifecycle ----------
|
|
88
|
+
def __enter__(self) -> "FinBrainClient":
|
|
89
|
+
"""Context manager entry."""
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
93
|
+
"""Context manager exit — close the underlying HTTP session."""
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
"""Close the underlying ``requests.Session``."""
|
|
98
|
+
self.session.close()
|
|
84
99
|
|
|
85
100
|
# ---------- private helpers ----------
|
|
86
101
|
def _request(
|
|
@@ -131,7 +146,7 @@ class FinBrainClient:
|
|
|
131
146
|
|
|
132
147
|
# ── Error path ───────────────────────────────────
|
|
133
148
|
if resp.status_code in _RETRYABLE_STATUS and attempt < self.retries:
|
|
134
|
-
#
|
|
149
|
+
# Transient server/gateway error – exponential back-off then retry
|
|
135
150
|
time.sleep(_BACKOFF_BASE**attempt)
|
|
136
151
|
continue
|
|
137
152
|
|
finbrain/endpoints/_utils.py
CHANGED
|
@@ -28,10 +28,16 @@ def to_datestr(value: _dt.date | str) -> str:
|
|
|
28
28
|
|
|
29
29
|
Examples
|
|
30
30
|
--------
|
|
31
|
-
>>> from datetime import date
|
|
31
|
+
>>> from datetime import date, datetime
|
|
32
32
|
>>> to_datestr(date(2025, 1, 15))
|
|
33
33
|
'2025-01-15'
|
|
34
|
+
>>> to_datestr(datetime(2025, 1, 15, 9, 30))
|
|
35
|
+
'2025-01-15'
|
|
34
36
|
>>> to_datestr("2025-01-15")
|
|
35
37
|
'2025-01-15'
|
|
36
38
|
"""
|
|
39
|
+
# ``datetime`` subclasses ``date``; take the date part so the time
|
|
40
|
+
# component never leaks into the ``YYYY-MM-DD`` API parameter.
|
|
41
|
+
if isinstance(value, _dt.datetime):
|
|
42
|
+
return value.date().isoformat()
|
|
37
43
|
return value.isoformat() if isinstance(value, _dt.date) else value
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, Any, List
|
|
5
|
+
|
|
6
|
+
from ._utils import to_datestr
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING: # imported only by type-checkers
|
|
9
|
+
from ..client import FinBrainClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PatentFilingsAPI:
|
|
13
|
+
"""
|
|
14
|
+
Endpoints
|
|
15
|
+
---------
|
|
16
|
+
``/patent-filings/<TICKER>`` - USPTO granted patents mapped to a ticker.
|
|
17
|
+
``/screener/patent-filings`` - cross-ticker patent filings screener.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------------ #
|
|
21
|
+
def __init__(self, client: "FinBrainClient") -> None:
|
|
22
|
+
self._c = client # reference to the parent client
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------ #
|
|
25
|
+
def ticker(
|
|
26
|
+
self,
|
|
27
|
+
symbol: str,
|
|
28
|
+
*,
|
|
29
|
+
date_from: _dt.date | str | None = None,
|
|
30
|
+
date_to: _dt.date | str | None = None,
|
|
31
|
+
limit: int | None = None,
|
|
32
|
+
as_dataframe: bool = False,
|
|
33
|
+
) -> Dict[str, Any] | pd.DataFrame:
|
|
34
|
+
"""
|
|
35
|
+
Fetch USPTO granted patents for *symbol*.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
symbol :
|
|
40
|
+
Ticker symbol; auto-upper-cased.
|
|
41
|
+
date_from, date_to :
|
|
42
|
+
Optional ISO dates (``YYYY-MM-DD``) bounding the returned rows by
|
|
43
|
+
grant date (``patentDate``).
|
|
44
|
+
limit :
|
|
45
|
+
Maximum number of records to return (1-500).
|
|
46
|
+
as_dataframe :
|
|
47
|
+
If *True*, return a **pandas.DataFrame** indexed by ``patentDate``;
|
|
48
|
+
otherwise return the raw JSON dict.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
dict | pandas.DataFrame
|
|
53
|
+
"""
|
|
54
|
+
params: Dict[str, str] = {}
|
|
55
|
+
if date_from:
|
|
56
|
+
params["startDate"] = to_datestr(date_from)
|
|
57
|
+
if date_to:
|
|
58
|
+
params["endDate"] = to_datestr(date_to)
|
|
59
|
+
if limit is not None:
|
|
60
|
+
params["limit"] = str(limit)
|
|
61
|
+
|
|
62
|
+
path = f"patent-filings/{symbol.upper()}"
|
|
63
|
+
|
|
64
|
+
data: Dict[str, Any] = self._c._request("GET", path, params=params)
|
|
65
|
+
|
|
66
|
+
if as_dataframe:
|
|
67
|
+
rows: List[Dict[str, Any]] = data.get("patents", [])
|
|
68
|
+
df = pd.DataFrame(rows)
|
|
69
|
+
if not df.empty and "patentDate" in df.columns:
|
|
70
|
+
df["patentDate"] = pd.to_datetime(df["patentDate"])
|
|
71
|
+
df.set_index("patentDate", inplace=True)
|
|
72
|
+
return df
|
|
73
|
+
|
|
74
|
+
return data
|
finbrain/endpoints/screener.py
CHANGED
|
@@ -239,3 +239,15 @@ class ScreenerAPI:
|
|
|
239
239
|
"""Screen government contracts across all tickers."""
|
|
240
240
|
params = self._build_params(limit=limit)
|
|
241
241
|
return self._get("screener/government-contracts", params, as_dataframe)
|
|
242
|
+
|
|
243
|
+
# ── patent filings ────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def patent_filings(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
limit: int | None = None,
|
|
249
|
+
as_dataframe: bool = False,
|
|
250
|
+
) -> List[Dict[str, Any]] | pd.DataFrame:
|
|
251
|
+
"""Screen USPTO patent filings across all tickers."""
|
|
252
|
+
params = self._build_params(limit=limit)
|
|
253
|
+
return self._get("screener/patent-filings", params, as_dataframe)
|
finbrain/exceptions.py
CHANGED
|
@@ -14,6 +14,9 @@ Docs-based mapping
|
|
|
14
14
|
405 Method Not Allowed → MethodNotAllowed
|
|
15
15
|
429 Rate Limit Exceeded → RateLimitError
|
|
16
16
|
500 Internal Server Error → ServerError
|
|
17
|
+
502 Bad Gateway → BadGateway
|
|
18
|
+
503 Service Unavailable → ServiceUnavailable
|
|
19
|
+
504 Gateway Timeout → GatewayTimeout
|
|
17
20
|
"""
|
|
18
21
|
|
|
19
22
|
from __future__ import annotations
|
|
@@ -29,6 +32,9 @@ __all__ = [
|
|
|
29
32
|
"MethodNotAllowed",
|
|
30
33
|
"RateLimitError",
|
|
31
34
|
"ServerError",
|
|
35
|
+
"BadGateway",
|
|
36
|
+
"ServiceUnavailable",
|
|
37
|
+
"GatewayTimeout",
|
|
32
38
|
#
|
|
33
39
|
"InvalidResponse",
|
|
34
40
|
"http_error_to_exception",
|
|
@@ -96,6 +102,18 @@ class ServerError(FinBrainError):
|
|
|
96
102
|
"""500 - Internal error on FinBrain's side. Retrying later may help."""
|
|
97
103
|
|
|
98
104
|
|
|
105
|
+
class BadGateway(FinBrainError):
|
|
106
|
+
"""502 - Invalid response from an upstream server. Usually transient."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ServiceUnavailable(FinBrainError):
|
|
110
|
+
"""503 - Service temporarily unavailable (overloaded or in maintenance)."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class GatewayTimeout(FinBrainError):
|
|
114
|
+
"""504 - Upstream server did not respond in time. Usually transient."""
|
|
115
|
+
|
|
116
|
+
|
|
99
117
|
# ─────────────────────────────────────────────────────────────
|
|
100
118
|
# Transport / decoding guard
|
|
101
119
|
# ─────────────────────────────────────────────────────────────
|
|
@@ -167,6 +185,12 @@ def http_error_to_exception(resp) -> FinBrainError: # expects requests.Response
|
|
|
167
185
|
return RateLimitError(message, **kwargs)
|
|
168
186
|
if status == 500:
|
|
169
187
|
return ServerError(message, **kwargs)
|
|
188
|
+
if status == 502:
|
|
189
|
+
return BadGateway(message, **kwargs)
|
|
190
|
+
if status == 503:
|
|
191
|
+
return ServiceUnavailable(message, **kwargs)
|
|
192
|
+
if status == 504:
|
|
193
|
+
return GatewayTimeout(message, **kwargs)
|
|
170
194
|
|
|
171
195
|
# Fallback for undocumented codes (future-proofing)
|
|
172
196
|
return FinBrainError(message, **kwargs)
|
finbrain/plotting.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# src/finbrain/plotting.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
from typing import Union, TYPE_CHECKING
|
|
3
|
+
from typing import Literal, Union, TYPE_CHECKING
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pandas as pd
|
|
6
6
|
import plotly.graph_objects as go
|
|
@@ -46,7 +46,7 @@ class _PlotNamespace:
|
|
|
46
46
|
Other args/kwargs identical to the other plotting wrappers.
|
|
47
47
|
"""
|
|
48
48
|
# 1) pull data
|
|
49
|
-
df = self._fb.app_ratings.ticker(
|
|
49
|
+
df: pd.DataFrame = self._fb.app_ratings.ticker(
|
|
50
50
|
ticker,
|
|
51
51
|
date_from=date_from,
|
|
52
52
|
date_to=date_to,
|
|
@@ -150,7 +150,7 @@ class _PlotNamespace:
|
|
|
150
150
|
* **Bars** → ``employeeCount`` (primary y-axis)
|
|
151
151
|
* **Line** → ``followerCount`` (secondary y-axis)
|
|
152
152
|
"""
|
|
153
|
-
df = self._fb.linkedin_data.ticker(
|
|
153
|
+
df: pd.DataFrame = self._fb.linkedin_data.ticker(
|
|
154
154
|
ticker,
|
|
155
155
|
date_from=date_from,
|
|
156
156
|
date_to=date_to,
|
|
@@ -344,7 +344,7 @@ class _PlotNamespace:
|
|
|
344
344
|
... date_to="2025-05-31")
|
|
345
345
|
"""
|
|
346
346
|
if kind == "put_call":
|
|
347
|
-
df = self._fb.options.put_call(
|
|
347
|
+
df: pd.DataFrame = self._fb.options.put_call(
|
|
348
348
|
ticker,
|
|
349
349
|
date_from=date_from,
|
|
350
350
|
date_to=date_to,
|
|
@@ -368,7 +368,7 @@ class _PlotNamespace:
|
|
|
368
368
|
self,
|
|
369
369
|
ticker: str,
|
|
370
370
|
*,
|
|
371
|
-
prediction_type:
|
|
371
|
+
prediction_type: Literal["daily", "monthly"] = "daily",
|
|
372
372
|
as_json=False,
|
|
373
373
|
show=True,
|
|
374
374
|
template="plotly_dark",
|
|
@@ -398,7 +398,7 @@ class _PlotNamespace:
|
|
|
398
398
|
--------
|
|
399
399
|
>>> fb.plot.predictions("AMZN", prediction_type="monthly")
|
|
400
400
|
"""
|
|
401
|
-
df = self._fb.predictions.ticker(
|
|
401
|
+
df: pd.DataFrame = self._fb.predictions.ticker(
|
|
402
402
|
ticker, prediction_type=prediction_type, as_dataframe=True, **kw
|
|
403
403
|
)
|
|
404
404
|
|
|
@@ -496,30 +496,10 @@ class _PlotNamespace:
|
|
|
496
496
|
... }).set_index("date")
|
|
497
497
|
>>> fb.plot.insider_transactions("AAPL", price_df)
|
|
498
498
|
"""
|
|
499
|
-
|
|
500
|
-
if price_data.empty:
|
|
501
|
-
raise ValueError("price_data cannot be empty")
|
|
502
|
-
|
|
503
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
504
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
505
|
-
# Get the first level (price types like 'Close', 'Open', etc.)
|
|
506
|
-
price_data = price_data.copy()
|
|
507
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
508
|
-
|
|
509
|
-
# Find price column (case-insensitive search)
|
|
510
|
-
price_col = None
|
|
511
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
512
|
-
if col in price_data.columns:
|
|
513
|
-
price_col = col
|
|
514
|
-
break
|
|
515
|
-
if price_col is None:
|
|
516
|
-
raise ValueError(
|
|
517
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
518
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
519
|
-
)
|
|
499
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
520
500
|
|
|
521
501
|
# Fetch insider transactions
|
|
522
|
-
transactions_df = self._fb.insider_transactions.ticker(
|
|
502
|
+
transactions_df: pd.DataFrame = self._fb.insider_transactions.ticker(
|
|
523
503
|
ticker, as_dataframe=True, **kwargs
|
|
524
504
|
)
|
|
525
505
|
|
|
@@ -599,30 +579,10 @@ class _PlotNamespace:
|
|
|
599
579
|
>>> fb.plot.house_trades("AAPL", price_df,
|
|
600
580
|
... date_from="2024-01-01", date_to="2024-12-31")
|
|
601
581
|
"""
|
|
602
|
-
|
|
603
|
-
if price_data.empty:
|
|
604
|
-
raise ValueError("price_data cannot be empty")
|
|
605
|
-
|
|
606
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
607
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
608
|
-
# Get the first level (price types like 'Close', 'Open', etc.)
|
|
609
|
-
price_data = price_data.copy()
|
|
610
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
611
|
-
|
|
612
|
-
# Find price column (case-insensitive search)
|
|
613
|
-
price_col = None
|
|
614
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
615
|
-
if col in price_data.columns:
|
|
616
|
-
price_col = col
|
|
617
|
-
break
|
|
618
|
-
if price_col is None:
|
|
619
|
-
raise ValueError(
|
|
620
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
621
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
622
|
-
)
|
|
582
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
623
583
|
|
|
624
584
|
# Fetch house trades
|
|
625
|
-
transactions_df = self._fb.house_trades.ticker(
|
|
585
|
+
transactions_df: pd.DataFrame = self._fb.house_trades.ticker(
|
|
626
586
|
ticker,
|
|
627
587
|
date_from=date_from,
|
|
628
588
|
date_to=date_to,
|
|
@@ -706,30 +666,10 @@ class _PlotNamespace:
|
|
|
706
666
|
>>> fb.plot.senate_trades("META", price_df,
|
|
707
667
|
... date_from="2024-01-01", date_to="2024-12-31")
|
|
708
668
|
"""
|
|
709
|
-
|
|
710
|
-
if price_data.empty:
|
|
711
|
-
raise ValueError("price_data cannot be empty")
|
|
712
|
-
|
|
713
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
714
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
715
|
-
# Get the first level (price types like 'Close', 'Open', etc.)
|
|
716
|
-
price_data = price_data.copy()
|
|
717
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
718
|
-
|
|
719
|
-
# Find price column (case-insensitive search)
|
|
720
|
-
price_col = None
|
|
721
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
722
|
-
if col in price_data.columns:
|
|
723
|
-
price_col = col
|
|
724
|
-
break
|
|
725
|
-
if price_col is None:
|
|
726
|
-
raise ValueError(
|
|
727
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
728
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
729
|
-
)
|
|
669
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
730
670
|
|
|
731
671
|
# Fetch senate trades
|
|
732
|
-
transactions_df = self._fb.senate_trades.ticker(
|
|
672
|
+
transactions_df: pd.DataFrame = self._fb.senate_trades.ticker(
|
|
733
673
|
ticker,
|
|
734
674
|
date_from=date_from,
|
|
735
675
|
date_to=date_to,
|
|
@@ -802,29 +742,10 @@ class _PlotNamespace:
|
|
|
802
742
|
ValueError
|
|
803
743
|
If ``price_data`` is empty or missing required price column.
|
|
804
744
|
"""
|
|
805
|
-
|
|
806
|
-
if price_data.empty:
|
|
807
|
-
raise ValueError("price_data cannot be empty")
|
|
808
|
-
|
|
809
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
810
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
811
|
-
price_data = price_data.copy()
|
|
812
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
813
|
-
|
|
814
|
-
# Find price column (case-insensitive search)
|
|
815
|
-
price_col = None
|
|
816
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
817
|
-
if col in price_data.columns:
|
|
818
|
-
price_col = col
|
|
819
|
-
break
|
|
820
|
-
if price_col is None:
|
|
821
|
-
raise ValueError(
|
|
822
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
823
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
824
|
-
)
|
|
745
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
825
746
|
|
|
826
747
|
# Fetch lobbying filings
|
|
827
|
-
filings_df = self._fb.corporate_lobbying.ticker(
|
|
748
|
+
filings_df: pd.DataFrame = self._fb.corporate_lobbying.ticker(
|
|
828
749
|
ticker,
|
|
829
750
|
date_from=date_from,
|
|
830
751
|
date_to=date_to,
|
|
@@ -833,9 +754,7 @@ class _PlotNamespace:
|
|
|
833
754
|
)
|
|
834
755
|
|
|
835
756
|
# Normalize timezones
|
|
836
|
-
price_data_normalized =
|
|
837
|
-
if price_data_normalized.index.tz is not None:
|
|
838
|
-
price_data_normalized.index = price_data_normalized.index.tz_localize(None)
|
|
757
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
839
758
|
|
|
840
759
|
fig = go.Figure(
|
|
841
760
|
layout=dict(
|
|
@@ -857,9 +776,7 @@ class _PlotNamespace:
|
|
|
857
776
|
)
|
|
858
777
|
|
|
859
778
|
if not filings_df.empty:
|
|
860
|
-
filings_normalized =
|
|
861
|
-
if filings_normalized.index.tz is not None:
|
|
862
|
-
filings_normalized.index = filings_normalized.index.tz_localize(None)
|
|
779
|
+
filings_normalized = self._to_naive_index(filings_df)
|
|
863
780
|
|
|
864
781
|
# Compute total spend per filing (income + expenses)
|
|
865
782
|
spend = filings_normalized.get("income", 0) + filings_normalized.get(
|
|
@@ -958,29 +875,10 @@ class _PlotNamespace:
|
|
|
958
875
|
ValueError
|
|
959
876
|
If ``price_data`` is empty or missing required price column.
|
|
960
877
|
"""
|
|
961
|
-
|
|
962
|
-
if price_data.empty:
|
|
963
|
-
raise ValueError("price_data cannot be empty")
|
|
964
|
-
|
|
965
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
966
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
967
|
-
price_data = price_data.copy()
|
|
968
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
969
|
-
|
|
970
|
-
# Find price column (case-insensitive search)
|
|
971
|
-
price_col = None
|
|
972
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
973
|
-
if col in price_data.columns:
|
|
974
|
-
price_col = col
|
|
975
|
-
break
|
|
976
|
-
if price_col is None:
|
|
977
|
-
raise ValueError(
|
|
978
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
979
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
980
|
-
)
|
|
878
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
981
879
|
|
|
982
880
|
# Fetch Reddit mentions
|
|
983
|
-
mentions_df = self._fb.reddit_mentions.ticker(
|
|
881
|
+
mentions_df: pd.DataFrame = self._fb.reddit_mentions.ticker(
|
|
984
882
|
ticker,
|
|
985
883
|
date_from=date_from,
|
|
986
884
|
date_to=date_to,
|
|
@@ -989,9 +887,7 @@ class _PlotNamespace:
|
|
|
989
887
|
)
|
|
990
888
|
|
|
991
889
|
# Normalize timezones
|
|
992
|
-
price_data_normalized =
|
|
993
|
-
if price_data_normalized.index.tz is not None:
|
|
994
|
-
price_data_normalized.index = price_data_normalized.index.tz_localize(None)
|
|
890
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
995
891
|
|
|
996
892
|
fig = go.Figure(
|
|
997
893
|
layout=dict(
|
|
@@ -1013,9 +909,7 @@ class _PlotNamespace:
|
|
|
1013
909
|
)
|
|
1014
910
|
|
|
1015
911
|
if not mentions_df.empty:
|
|
1016
|
-
mentions_normalized =
|
|
1017
|
-
if mentions_normalized.index.tz is not None:
|
|
1018
|
-
mentions_normalized.index = mentions_normalized.index.tz_localize(None)
|
|
912
|
+
mentions_normalized = self._to_naive_index(mentions_df)
|
|
1019
913
|
|
|
1020
914
|
# Exclude _all (aggregate) — use individual subreddits for stacked bars
|
|
1021
915
|
per_sub = mentions_normalized[
|
|
@@ -1205,29 +1099,10 @@ class _PlotNamespace:
|
|
|
1205
1099
|
ValueError
|
|
1206
1100
|
If ``price_data`` is empty or missing required price column.
|
|
1207
1101
|
"""
|
|
1208
|
-
|
|
1209
|
-
if price_data.empty:
|
|
1210
|
-
raise ValueError("price_data cannot be empty")
|
|
1211
|
-
|
|
1212
|
-
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
1213
|
-
if isinstance(price_data.columns, pd.MultiIndex):
|
|
1214
|
-
price_data = price_data.copy()
|
|
1215
|
-
price_data.columns = price_data.columns.get_level_values(0)
|
|
1216
|
-
|
|
1217
|
-
# Find price column (case-insensitive search)
|
|
1218
|
-
price_col = None
|
|
1219
|
-
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
1220
|
-
if col in price_data.columns:
|
|
1221
|
-
price_col = col
|
|
1222
|
-
break
|
|
1223
|
-
if price_col is None:
|
|
1224
|
-
raise ValueError(
|
|
1225
|
-
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
1226
|
-
f"Found columns: {price_data.columns.tolist()}"
|
|
1227
|
-
)
|
|
1102
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
1228
1103
|
|
|
1229
1104
|
# Fetch government contracts
|
|
1230
|
-
contracts_df = self._fb.government_contracts.ticker(
|
|
1105
|
+
contracts_df: pd.DataFrame = self._fb.government_contracts.ticker(
|
|
1231
1106
|
ticker,
|
|
1232
1107
|
date_from=date_from,
|
|
1233
1108
|
date_to=date_to,
|
|
@@ -1236,9 +1111,7 @@ class _PlotNamespace:
|
|
|
1236
1111
|
)
|
|
1237
1112
|
|
|
1238
1113
|
# Normalize timezones
|
|
1239
|
-
price_data_normalized =
|
|
1240
|
-
if price_data_normalized.index.tz is not None:
|
|
1241
|
-
price_data_normalized.index = price_data_normalized.index.tz_localize(None)
|
|
1114
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
1242
1115
|
|
|
1243
1116
|
fig = go.Figure(
|
|
1244
1117
|
layout=dict(
|
|
@@ -1260,9 +1133,7 @@ class _PlotNamespace:
|
|
|
1260
1133
|
)
|
|
1261
1134
|
|
|
1262
1135
|
if not contracts_df.empty:
|
|
1263
|
-
contracts_normalized =
|
|
1264
|
-
if contracts_normalized.index.tz is not None:
|
|
1265
|
-
contracts_normalized.index = contracts_normalized.index.tz_localize(None)
|
|
1136
|
+
contracts_normalized = self._to_naive_index(contracts_df)
|
|
1266
1137
|
|
|
1267
1138
|
amounts = contracts_normalized.get("awardAmount", pd.Series(dtype=float))
|
|
1268
1139
|
|
|
@@ -1308,12 +1179,365 @@ class _PlotNamespace:
|
|
|
1308
1179
|
return None
|
|
1309
1180
|
return fig.to_json() if as_json else fig
|
|
1310
1181
|
|
|
1182
|
+
def patent_filings(
|
|
1183
|
+
self,
|
|
1184
|
+
ticker: str,
|
|
1185
|
+
price_data: pd.DataFrame,
|
|
1186
|
+
*,
|
|
1187
|
+
date_from: str | None = None,
|
|
1188
|
+
date_to: str | None = None,
|
|
1189
|
+
as_json: bool = False,
|
|
1190
|
+
show: bool = True,
|
|
1191
|
+
template: str = "plotly_dark",
|
|
1192
|
+
**kwargs,
|
|
1193
|
+
):
|
|
1194
|
+
"""
|
|
1195
|
+
Plot USPTO granted patents overlaid on a price chart.
|
|
1196
|
+
|
|
1197
|
+
Each granted patent is drawn as a bar (sized by its claim count) on a
|
|
1198
|
+
secondary y-axis, positioned at the patent's grant date. This method
|
|
1199
|
+
requires user-provided historical price data, as FinBrain does not
|
|
1200
|
+
currently offer a price history endpoint.
|
|
1201
|
+
|
|
1202
|
+
Parameters
|
|
1203
|
+
----------
|
|
1204
|
+
ticker : str
|
|
1205
|
+
Ticker symbol (e.g. ``"AAPL"``).
|
|
1206
|
+
price_data : pandas.DataFrame
|
|
1207
|
+
**User-provided** price history with a DatetimeIndex and a column
|
|
1208
|
+
containing prices (e.g. ``"close"``, ``"Close"``, or ``"price"``).
|
|
1209
|
+
The index must be timezone-naive or UTC.
|
|
1210
|
+
date_from, date_to : str or None, optional
|
|
1211
|
+
Date range for patents in ``YYYY-MM-DD`` format (filters grant date).
|
|
1212
|
+
as_json : bool, default False
|
|
1213
|
+
If ``True``, return JSON string instead of Figure object.
|
|
1214
|
+
show : bool, default True
|
|
1215
|
+
If ``True`` and ``as_json=False``, display the figure immediately.
|
|
1216
|
+
template : str, default "plotly_dark"
|
|
1217
|
+
Plotly template name.
|
|
1218
|
+
**kwargs
|
|
1219
|
+
Additional arguments passed to
|
|
1220
|
+
:meth:`FinBrainClient.patent_filings.ticker`.
|
|
1221
|
+
|
|
1222
|
+
Returns
|
|
1223
|
+
-------
|
|
1224
|
+
plotly.graph_objects.Figure or str or None
|
|
1225
|
+
Figure object, JSON string, or None (when shown).
|
|
1226
|
+
|
|
1227
|
+
Raises
|
|
1228
|
+
------
|
|
1229
|
+
ValueError
|
|
1230
|
+
If ``price_data`` is empty or missing required price column.
|
|
1231
|
+
"""
|
|
1232
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
1233
|
+
|
|
1234
|
+
# Fetch patent filings
|
|
1235
|
+
patents_df: pd.DataFrame = self._fb.patent_filings.ticker(
|
|
1236
|
+
ticker,
|
|
1237
|
+
date_from=date_from,
|
|
1238
|
+
date_to=date_to,
|
|
1239
|
+
as_dataframe=True,
|
|
1240
|
+
**kwargs,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Normalize timezones
|
|
1244
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
1245
|
+
|
|
1246
|
+
fig = go.Figure(
|
|
1247
|
+
layout=dict(
|
|
1248
|
+
template=template,
|
|
1249
|
+
title=f"Patent Filings · {ticker}",
|
|
1250
|
+
xaxis_title="Date",
|
|
1251
|
+
hovermode="x unified",
|
|
1252
|
+
)
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
# Plot price line on primary y-axis
|
|
1256
|
+
fig.add_scatter(
|
|
1257
|
+
name="Price",
|
|
1258
|
+
x=price_data_normalized.index,
|
|
1259
|
+
y=price_data_normalized[price_col],
|
|
1260
|
+
mode="lines",
|
|
1261
|
+
line=dict(width=2, color="#02d2ff"),
|
|
1262
|
+
hovertemplate="<b>%{x|%Y-%m-%d}</b><br>Price: $%{y:.2f}<extra></extra>",
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
if not patents_df.empty:
|
|
1266
|
+
patents_normalized = self._to_naive_index(patents_df)
|
|
1267
|
+
|
|
1268
|
+
claims = patents_normalized.get("numClaims", pd.Series(dtype=float))
|
|
1269
|
+
|
|
1270
|
+
hover_text = []
|
|
1271
|
+
for _, row in patents_normalized.iterrows():
|
|
1272
|
+
title = row.get("title", "")
|
|
1273
|
+
if len(str(title)) > 80:
|
|
1274
|
+
title = str(title)[:80] + "…"
|
|
1275
|
+
ptype = row.get("type", "N/A")
|
|
1276
|
+
section = row.get("primaryCpcSection", "")
|
|
1277
|
+
n_claims = row.get("numClaims", 0)
|
|
1278
|
+
hover_text.append(
|
|
1279
|
+
f"Title: {title}<br>"
|
|
1280
|
+
f"Type: {ptype}<br>"
|
|
1281
|
+
f"CPC Section: {section}<br>"
|
|
1282
|
+
f"Claims: {n_claims}"
|
|
1283
|
+
)
|
|
1284
|
+
|
|
1285
|
+
fig.add_bar(
|
|
1286
|
+
name="Patent Grant",
|
|
1287
|
+
x=patents_normalized.index,
|
|
1288
|
+
y=claims,
|
|
1289
|
+
marker_color="rgba(249,200,14,0.6)",
|
|
1290
|
+
yaxis="y2",
|
|
1291
|
+
hovertext=hover_text,
|
|
1292
|
+
hovertemplate="<b>%{x|%Y-%m-%d}</b><br>%{hovertext}<extra></extra>",
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
fig.update_layout(
|
|
1296
|
+
yaxis=dict(title="Price", showgrid=True),
|
|
1297
|
+
yaxis2=dict(
|
|
1298
|
+
title="Claims",
|
|
1299
|
+
overlaying="y",
|
|
1300
|
+
side="right",
|
|
1301
|
+
showgrid=False,
|
|
1302
|
+
zeroline=False,
|
|
1303
|
+
rangemode="tozero",
|
|
1304
|
+
),
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
if show and not as_json:
|
|
1308
|
+
fig.show()
|
|
1309
|
+
return None
|
|
1310
|
+
return fig.to_json() if as_json else fig
|
|
1311
|
+
|
|
1312
|
+
def analyst_ratings(
|
|
1313
|
+
self,
|
|
1314
|
+
ticker: str,
|
|
1315
|
+
price_data: pd.DataFrame,
|
|
1316
|
+
*,
|
|
1317
|
+
date_from: str | None = None,
|
|
1318
|
+
date_to: str | None = None,
|
|
1319
|
+
as_json: bool = False,
|
|
1320
|
+
show: bool = True,
|
|
1321
|
+
template: str = "plotly_dark",
|
|
1322
|
+
**kwargs,
|
|
1323
|
+
):
|
|
1324
|
+
"""
|
|
1325
|
+
Plot analyst rating actions and price targets overlaid on a price chart.
|
|
1326
|
+
|
|
1327
|
+
Each rating is drawn as a marker at its **target price** (where one is
|
|
1328
|
+
provided) or, when no target is given, at the prevailing price on that
|
|
1329
|
+
date, so every action remains visible. Markers are grouped and coloured
|
|
1330
|
+
by action category (upgrade, downgrade, initiate, maintain, other). This
|
|
1331
|
+
method requires user-provided historical price data, as FinBrain does
|
|
1332
|
+
not currently offer a price history endpoint.
|
|
1333
|
+
|
|
1334
|
+
Parameters
|
|
1335
|
+
----------
|
|
1336
|
+
ticker : str
|
|
1337
|
+
Ticker symbol (e.g. ``"AAPL"``).
|
|
1338
|
+
price_data : pandas.DataFrame
|
|
1339
|
+
**User-provided** price history with a DatetimeIndex and a column
|
|
1340
|
+
containing prices (e.g. ``"close"``, ``"Close"``, or ``"price"``).
|
|
1341
|
+
The index must be timezone-naive or UTC.
|
|
1342
|
+
date_from, date_to : str or None, optional
|
|
1343
|
+
Date range for ratings in ``YYYY-MM-DD`` format.
|
|
1344
|
+
as_json : bool, default False
|
|
1345
|
+
If ``True``, return JSON string instead of Figure object.
|
|
1346
|
+
show : bool, default True
|
|
1347
|
+
If ``True`` and ``as_json=False``, display the figure immediately.
|
|
1348
|
+
template : str, default "plotly_dark"
|
|
1349
|
+
Plotly template name.
|
|
1350
|
+
**kwargs
|
|
1351
|
+
Additional arguments passed to
|
|
1352
|
+
:meth:`FinBrainClient.analyst_ratings.ticker`.
|
|
1353
|
+
|
|
1354
|
+
Returns
|
|
1355
|
+
-------
|
|
1356
|
+
plotly.graph_objects.Figure or str or None
|
|
1357
|
+
Figure object, JSON string, or None (when shown).
|
|
1358
|
+
|
|
1359
|
+
Raises
|
|
1360
|
+
------
|
|
1361
|
+
ValueError
|
|
1362
|
+
If ``price_data`` is empty or missing required price column.
|
|
1363
|
+
"""
|
|
1364
|
+
price_data, price_col = self._resolve_price_column(price_data)
|
|
1365
|
+
|
|
1366
|
+
# Fetch analyst ratings
|
|
1367
|
+
ratings_df: pd.DataFrame = self._fb.analyst_ratings.ticker(
|
|
1368
|
+
ticker,
|
|
1369
|
+
date_from=date_from,
|
|
1370
|
+
date_to=date_to,
|
|
1371
|
+
as_dataframe=True,
|
|
1372
|
+
**kwargs,
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
# Normalize timezones
|
|
1376
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
1377
|
+
|
|
1378
|
+
fig = go.Figure(
|
|
1379
|
+
layout=dict(
|
|
1380
|
+
template=template,
|
|
1381
|
+
title=f"Analyst Ratings · {ticker}",
|
|
1382
|
+
xaxis_title="Date",
|
|
1383
|
+
yaxis_title="Price / Target ($)",
|
|
1384
|
+
hovermode="x unified",
|
|
1385
|
+
)
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
# Plot price line on primary y-axis
|
|
1389
|
+
fig.add_scatter(
|
|
1390
|
+
name="Price",
|
|
1391
|
+
x=price_data_normalized.index,
|
|
1392
|
+
y=price_data_normalized[price_col],
|
|
1393
|
+
mode="lines",
|
|
1394
|
+
line=dict(width=2, color="#02d2ff"),
|
|
1395
|
+
hovertemplate="<b>%{x|%Y-%m-%d}</b><br>Price: $%{y:.2f}<extra></extra>",
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
if not ratings_df.empty:
|
|
1399
|
+
ratings_normalized = self._to_naive_index(ratings_df)
|
|
1400
|
+
|
|
1401
|
+
targets = pd.to_numeric(
|
|
1402
|
+
ratings_normalized.get("targetPrice"), errors="coerce"
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
def _price_at(when):
|
|
1406
|
+
"""Nearest available price for a rating date."""
|
|
1407
|
+
if when in price_data_normalized.index:
|
|
1408
|
+
return price_data_normalized.loc[when, price_col]
|
|
1409
|
+
idx = price_data_normalized.index.get_indexer([when], method="nearest")[0]
|
|
1410
|
+
if 0 <= idx < len(price_data_normalized):
|
|
1411
|
+
return price_data_normalized.iloc[idx][price_col]
|
|
1412
|
+
return None
|
|
1413
|
+
|
|
1414
|
+
# action category → (legend label, colour)
|
|
1415
|
+
categories = {
|
|
1416
|
+
"upgrade": ("Upgrade", "#26a69a"),
|
|
1417
|
+
"downgrade": ("Downgrade", "#ef5350"),
|
|
1418
|
+
"initiate": ("Initiate", "#42a5f5"),
|
|
1419
|
+
"maintain": ("Maintain", "#bdbdbd"),
|
|
1420
|
+
"other": ("Other", "#f9c80e"),
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
def _categorize(action: str) -> str:
|
|
1424
|
+
a = str(action).lower()
|
|
1425
|
+
if "upgrade" in a:
|
|
1426
|
+
return "upgrade"
|
|
1427
|
+
if "downgrade" in a:
|
|
1428
|
+
return "downgrade"
|
|
1429
|
+
if "initiat" in a:
|
|
1430
|
+
return "initiate"
|
|
1431
|
+
if any(k in a for k in ("maintain", "reiterat", "reaffirm", "hold")):
|
|
1432
|
+
return "maintain"
|
|
1433
|
+
return "other"
|
|
1434
|
+
|
|
1435
|
+
# Bucket each rating row by action category
|
|
1436
|
+
buckets: dict[str, dict[str, list]] = {
|
|
1437
|
+
key: {"x": [], "y": [], "symbol": [], "hover": []}
|
|
1438
|
+
for key in categories
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
for pos, (when, row) in enumerate(ratings_normalized.iterrows()):
|
|
1442
|
+
target = targets.iloc[pos]
|
|
1443
|
+
has_target = pd.notna(target)
|
|
1444
|
+
y_val = target if has_target else _price_at(when)
|
|
1445
|
+
if y_val is None:
|
|
1446
|
+
continue
|
|
1447
|
+
|
|
1448
|
+
cat = _categorize(row.get("action", ""))
|
|
1449
|
+
bucket = buckets[cat]
|
|
1450
|
+
bucket["x"].append(when)
|
|
1451
|
+
bucket["y"].append(y_val)
|
|
1452
|
+
# diamond = plotted at target, open circle = plotted at price
|
|
1453
|
+
bucket["symbol"].append("diamond" if has_target else "circle-open")
|
|
1454
|
+
target_str = f"${target:,.2f}" if has_target else "n/a"
|
|
1455
|
+
bucket["hover"].append(
|
|
1456
|
+
f"Institution: {row.get('institution', 'N/A')}<br>"
|
|
1457
|
+
f"Action: {row.get('action', 'N/A')}<br>"
|
|
1458
|
+
f"Rating: {row.get('rating', 'N/A')}<br>"
|
|
1459
|
+
f"Target: {target_str}"
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
for key, (label, color) in categories.items():
|
|
1463
|
+
bucket = buckets[key]
|
|
1464
|
+
if not bucket["x"]:
|
|
1465
|
+
continue
|
|
1466
|
+
fig.add_scatter(
|
|
1467
|
+
name=label,
|
|
1468
|
+
x=bucket["x"],
|
|
1469
|
+
y=bucket["y"],
|
|
1470
|
+
mode="markers",
|
|
1471
|
+
marker=dict(
|
|
1472
|
+
size=10,
|
|
1473
|
+
color=color,
|
|
1474
|
+
symbol=bucket["symbol"],
|
|
1475
|
+
line=dict(width=1, color="#000000"),
|
|
1476
|
+
),
|
|
1477
|
+
hovertext=bucket["hover"],
|
|
1478
|
+
hovertemplate="<b>%{x|%Y-%m-%d}</b><br>%{hovertext}<extra></extra>",
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
if show and not as_json:
|
|
1482
|
+
fig.show()
|
|
1483
|
+
return None
|
|
1484
|
+
return fig.to_json() if as_json else fig
|
|
1485
|
+
|
|
1311
1486
|
# --------------------------------------------------------------------- #
|
|
1312
1487
|
# Helper methods #
|
|
1313
1488
|
# --------------------------------------------------------------------- #
|
|
1314
1489
|
|
|
1315
1490
|
@staticmethod
|
|
1491
|
+
def _resolve_price_column(price_data: pd.DataFrame) -> tuple[pd.DataFrame, str]:
|
|
1492
|
+
"""
|
|
1493
|
+
Validate ``price_data`` and locate its price column.
|
|
1494
|
+
|
|
1495
|
+
Flattens a MultiIndex column layout (e.g. from ``yf.download()``) and
|
|
1496
|
+
searches for a known price column case-insensitively.
|
|
1497
|
+
|
|
1498
|
+
Returns
|
|
1499
|
+
-------
|
|
1500
|
+
tuple[pandas.DataFrame, str]
|
|
1501
|
+
The (possibly flattened) frame and the resolved price column name.
|
|
1502
|
+
|
|
1503
|
+
Raises
|
|
1504
|
+
------
|
|
1505
|
+
ValueError
|
|
1506
|
+
If ``price_data`` is empty or has no recognised price column.
|
|
1507
|
+
"""
|
|
1508
|
+
if price_data.empty:
|
|
1509
|
+
raise ValueError("price_data cannot be empty")
|
|
1510
|
+
|
|
1511
|
+
# Flatten MultiIndex columns if present (e.g., from yf.download())
|
|
1512
|
+
if isinstance(price_data.columns, pd.MultiIndex):
|
|
1513
|
+
price_data = price_data.copy()
|
|
1514
|
+
price_data.columns = price_data.columns.get_level_values(0)
|
|
1515
|
+
|
|
1516
|
+
for col in ["close", "Close", "price", "Price", "adj_close", "Adj Close"]:
|
|
1517
|
+
if col in price_data.columns:
|
|
1518
|
+
return price_data, col
|
|
1519
|
+
|
|
1520
|
+
raise ValueError(
|
|
1521
|
+
f"price_data must contain a price column (e.g. 'close', 'Close', 'price'). "
|
|
1522
|
+
f"Found columns: {price_data.columns.tolist()}"
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
@staticmethod
|
|
1526
|
+
def _to_naive_index(df: pd.DataFrame) -> pd.DataFrame:
|
|
1527
|
+
"""
|
|
1528
|
+
Return a copy of ``df`` with a timezone-naive DatetimeIndex.
|
|
1529
|
+
|
|
1530
|
+
yfinance often returns timezone-aware data while FinBrain returns naive
|
|
1531
|
+
timestamps; normalising both sides lets them be compared and plotted
|
|
1532
|
+
together.
|
|
1533
|
+
"""
|
|
1534
|
+
out = df.copy()
|
|
1535
|
+
if out.index.tz is not None:
|
|
1536
|
+
out.index = out.index.tz_localize(None)
|
|
1537
|
+
return out
|
|
1538
|
+
|
|
1316
1539
|
def _plot_transactions_on_price(
|
|
1540
|
+
self,
|
|
1317
1541
|
price_data: pd.DataFrame,
|
|
1318
1542
|
price_col: str,
|
|
1319
1543
|
transactions_df: pd.DataFrame,
|
|
@@ -1344,17 +1568,10 @@ class _PlotNamespace:
|
|
|
1344
1568
|
-------
|
|
1345
1569
|
go.Figure
|
|
1346
1570
|
"""
|
|
1347
|
-
# Normalize timezones
|
|
1348
|
-
#
|
|
1349
|
-
price_data_normalized =
|
|
1350
|
-
|
|
1351
|
-
price_data_normalized.index = price_data_normalized.index.tz_localize(None)
|
|
1352
|
-
|
|
1353
|
-
transactions_df_normalized = transactions_df.copy()
|
|
1354
|
-
if transactions_df_normalized.index.tz is not None:
|
|
1355
|
-
transactions_df_normalized.index = (
|
|
1356
|
-
transactions_df_normalized.index.tz_localize(None)
|
|
1357
|
-
)
|
|
1571
|
+
# Normalize timezones so yfinance (often tz-aware) and FinBrain (naive)
|
|
1572
|
+
# timestamps can be compared.
|
|
1573
|
+
price_data_normalized = self._to_naive_index(price_data)
|
|
1574
|
+
transactions_df_normalized = self._to_naive_index(transactions_df)
|
|
1358
1575
|
|
|
1359
1576
|
fig = go.Figure(
|
|
1360
1577
|
layout=dict(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: finbrain-python
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Official Python client for the FinBrain API
|
|
5
5
|
Author-email: Ahmet Salim Bilgin <ahmet@finbrain.tech>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Requires-Dist: httpx>=0.24; extra == "dev"
|
|
|
22
22
|
Requires-Dist: build; extra == "dev"
|
|
23
23
|
Requires-Dist: twine; extra == "dev"
|
|
24
24
|
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Requires-Dist: mypy; extra == "dev"
|
|
26
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
25
27
|
Dynamic: license-file
|
|
26
28
|
|
|
27
29
|
# FinBrain Python SDK <!-- omit in toc -->
|
|
@@ -112,6 +114,13 @@ fb.government_contracts.ticker("LMT",
|
|
|
112
114
|
limit=50,
|
|
113
115
|
as_dataframe=True)
|
|
114
116
|
|
|
117
|
+
# ---------- patent filings ----------
|
|
118
|
+
fb.patent_filings.ticker("AAPL",
|
|
119
|
+
date_from="2025-01-01",
|
|
120
|
+
date_to="2025-12-31",
|
|
121
|
+
limit=50,
|
|
122
|
+
as_dataframe=True)
|
|
123
|
+
|
|
115
124
|
# ---------- insider transactions ----------
|
|
116
125
|
fb.insider_transactions.ticker("AMZN", as_dataframe=True)
|
|
117
126
|
|
|
@@ -145,6 +154,7 @@ fb.screener.predictions_daily(limit=100, as_dataframe=True)
|
|
|
145
154
|
fb.screener.insider_trading(limit=50)
|
|
146
155
|
fb.screener.reddit_mentions(limit=100, as_dataframe=True)
|
|
147
156
|
fb.screener.government_contracts(limit=100, as_dataframe=True)
|
|
157
|
+
fb.screener.patent_filings(limit=100, as_dataframe=True)
|
|
148
158
|
|
|
149
159
|
# ---------- recent data ----------
|
|
150
160
|
fb.recent.news(limit=100, as_dataframe=True)
|
|
@@ -265,6 +275,18 @@ fb.plot.reddit_mentions("TSLA",
|
|
|
265
275
|
price_data=price_df,
|
|
266
276
|
date_from="2026-03-01",
|
|
267
277
|
date_to="2026-03-17")
|
|
278
|
+
|
|
279
|
+
# Plot patent grants (bars sized by claim count) on your price chart
|
|
280
|
+
fb.plot.patent_filings("AAPL",
|
|
281
|
+
price_data=price_df,
|
|
282
|
+
date_from="2024-01-01",
|
|
283
|
+
date_to="2025-06-30")
|
|
284
|
+
|
|
285
|
+
# Plot analyst ratings & price targets (markers coloured by action) on your price chart
|
|
286
|
+
fb.plot.analyst_ratings("AAPL",
|
|
287
|
+
price_data=price_df,
|
|
288
|
+
date_from="2024-01-01",
|
|
289
|
+
date_to="2025-06-30")
|
|
268
290
|
```
|
|
269
291
|
|
|
270
292
|
```python
|
|
@@ -321,6 +343,7 @@ fb = FinBrainClient() # reads from FINBRAIN_API_KEY env var
|
|
|
321
343
|
| Corporate lobbying | `client.corporate_lobbying.ticker()` | `/lobbying/{SYMBOL}` |
|
|
322
344
|
| Reddit mentions | `client.reddit_mentions.ticker()` | `/reddit-mentions/{SYMBOL}` |
|
|
323
345
|
| Gov. contracts | `client.government_contracts.ticker()` | `/government-contracts/{SYMBOL}` |
|
|
346
|
+
| Patent filings | `client.patent_filings.ticker()` | `/patent-filings/{SYMBOL}` |
|
|
324
347
|
| Insider transactions | `client.insider_transactions.ticker()` | `/insider-trading/{SYMBOL}` |
|
|
325
348
|
| LinkedIn | `client.linkedin_data.ticker()` | `/linkedin/{SYMBOL}` |
|
|
326
349
|
| Options – Put/Call | `client.options.put_call()` | `/put-call-ratio/{SYMBOL}` |
|
|
@@ -329,6 +352,7 @@ fb = FinBrainClient() # reads from FINBRAIN_API_KEY env var
|
|
|
329
352
|
| | `client.screener.insider_trading()` | `/screener/insider-trading` |
|
|
330
353
|
| | `client.screener.reddit_mentions()` | `/screener/reddit-mentions` |
|
|
331
354
|
| | `client.screener.government_contracts()` | `/screener/government-contracts` |
|
|
355
|
+
| | `client.screener.patent_filings()` | `/screener/patent-filings` |
|
|
332
356
|
| | ... and 8 more screener methods | |
|
|
333
357
|
| Recent | `client.recent.news()` | `/recent/news` |
|
|
334
358
|
| | `client.recent.analyst_ratings()` | `/recent/analyst-ratings` |
|
|
@@ -356,6 +380,9 @@ except BadRequest as exc:
|
|
|
356
380
|
| 405 | `MethodNotAllowed` | HTTP method not supported on endpoint |
|
|
357
381
|
| 429 | `RateLimitError` | Too many requests |
|
|
358
382
|
| 500 | `ServerError` | FinBrain internal error |
|
|
383
|
+
| 502 | `BadGateway` | Invalid response from upstream server |
|
|
384
|
+
| 503 | `ServiceUnavailable` | Service temporarily unavailable |
|
|
385
|
+
| 504 | `GatewayTimeout` | Upstream server timed out |
|
|
359
386
|
|
|
360
387
|
---
|
|
361
388
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
finbrain/__init__.py,sha256=ULIgwHkWbj5sT0udC3SlTROhZCgm2y9BtPox-4joNn4,416
|
|
2
|
-
finbrain/client.py,sha256=
|
|
3
|
-
finbrain/exceptions.py,sha256=
|
|
4
|
-
finbrain/plotting.py,sha256=
|
|
2
|
+
finbrain/client.py,sha256=iu39iTUSPmiBcB-7gEJMvxHtkHXDSRVfq03Grw9UOVY,5850
|
|
3
|
+
finbrain/exceptions.py,sha256=xgn1UMirprJDhBNUVjxJUMfqKxHdFoBmkvCw3Hb5CH0,7019
|
|
4
|
+
finbrain/plotting.py,sha256=gvw2PuFwOy0vg3GRSfaQb5sSl6SdKH1zhY5USPShtn4,60606
|
|
5
5
|
finbrain/py.typed,sha256=INFR1qO3jdMrtVPs0N0SAVKVbjKV80HQPoPshwGEpGo,27
|
|
6
6
|
finbrain/aio/__init__.py,sha256=eJra9SqvLMkq5zoZfG6WFw7kd1jGpf9l-ovuPMeWvdw,125
|
|
7
|
-
finbrain/aio/client.py,sha256=
|
|
7
|
+
finbrain/aio/client.py,sha256=I5oMft5T-ttBRw31-qFItlYPdIzj0qG30pNx0_SHXNA,7367
|
|
8
8
|
finbrain/aio/endpoints/__init__.py,sha256=zdNSU1jzvnK6JnHxrvb3ye9KFCkA86xDTu9bBzmwrEQ,48
|
|
9
|
-
finbrain/aio/endpoints/_utils.py,sha256=
|
|
9
|
+
finbrain/aio/endpoints/_utils.py,sha256=yyritVvWL_boiHzgomY0_J3OHSr9CRr6Ihna-Chihfw,317
|
|
10
10
|
finbrain/aio/endpoints/analyst_ratings.py,sha256=IYG-PU8HDOZoyyMcb_2KRxhiMVicYyaU_TOCVZPWroo,1451
|
|
11
11
|
finbrain/aio/endpoints/app_ratings.py,sha256=QrJoLc7roTPRfInAEMwlr9vtxzruFJulGdB6msz7Eg0,2177
|
|
12
12
|
finbrain/aio/endpoints/available.py,sha256=rQuffHowNF7FrVV5Ds-KE5iFjdz0I_vD7Qk2_zY2WLo,2315
|
|
@@ -17,14 +17,15 @@ finbrain/aio/endpoints/insider_transactions.py,sha256=EBqHdvapYWvI53PM_sQy0caN2r
|
|
|
17
17
|
finbrain/aio/endpoints/linkedin_data.py,sha256=ucnkyNc1waiFHLxas1onPhxh9Y7ekcI_gOfUJ5gu1hI,1469
|
|
18
18
|
finbrain/aio/endpoints/news.py,sha256=NJkcpoP8ngNsJdCzQab8yaXJzNe5ypYstzq3-mEHfqU,1446
|
|
19
19
|
finbrain/aio/endpoints/options.py,sha256=k1XL3ktoFL2Exw6UE2XfU-EcS1Xw8BPXQzABLYXmfxk,1442
|
|
20
|
+
finbrain/aio/endpoints/patent_filings.py,sha256=Wumihz2X7knKZNF36X9t0UJ8mk0ydg6H7qdXFhEd4LQ,1513
|
|
20
21
|
finbrain/aio/endpoints/predictions.py,sha256=BrUSmqy0v7ngXyoE_UHgqdght8qtJ8IRxAhk_s9UeR0,1285
|
|
21
22
|
finbrain/aio/endpoints/recent.py,sha256=TSaAwf5w86O1plSmm4eg_gKSjSap2Z8rd2i7FAvgm90,2302
|
|
22
23
|
finbrain/aio/endpoints/reddit_mentions.py,sha256=P9ToZRaRN521RJ9W2J8cBCySUsNT47ROe-8TTdVtlqA,1508
|
|
23
|
-
finbrain/aio/endpoints/screener.py,sha256=
|
|
24
|
+
finbrain/aio/endpoints/screener.py,sha256=bmBs0uTrt7B8byaGfLTg8IfZckE_5GpeoUty79bea4Q,10272
|
|
24
25
|
finbrain/aio/endpoints/senate_trades.py,sha256=nhZd8R7Tclxzn-J4-ZWZ5onvBBd8pVOtiLfhKFtFBHw,1431
|
|
25
26
|
finbrain/aio/endpoints/sentiments.py,sha256=BXFT0jr5j0MnB65MXGxZK36BJLXMTuggkFF9A9eenEQ,1532
|
|
26
27
|
finbrain/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
-
finbrain/endpoints/_utils.py,sha256=
|
|
28
|
+
finbrain/endpoints/_utils.py,sha256=wY8SKthpVernWlKwZfWoM8zZtQ3GS_hBQioH1Fk7RK4,1190
|
|
28
29
|
finbrain/endpoints/analyst_ratings.py,sha256=6q_tecgnbzXn2wLSWqHNUa9MbLcybTD7xEBAkUyGPR0,2218
|
|
29
30
|
finbrain/endpoints/app_ratings.py,sha256=mbeKiJ87nNibnz9AT2kmH4fVFA4goEcld910abm3tFs,3597
|
|
30
31
|
finbrain/endpoints/available.py,sha256=3cXjDK1qE38RHAfQ2C14oD7l7dR8mhDtUcva3LzlK5s,3911
|
|
@@ -35,14 +36,15 @@ finbrain/endpoints/insider_transactions.py,sha256=TU6EsSHIeams4OLJ9s8BpNuXeBnWPH
|
|
|
35
36
|
finbrain/endpoints/linkedin_data.py,sha256=zcNboQ-Ejt_7Ncu3p9WCd-PsjEBFbojVaJZdkbaGT8Q,2215
|
|
36
37
|
finbrain/endpoints/news.py,sha256=q4aaEiunrUbl1FF0JZuX9__cZWxm2r1-B9x-c8g4Ia8,1913
|
|
37
38
|
finbrain/endpoints/options.py,sha256=_TYWPcJ-gmPTQRg_kEVQl6Oj6fAirtZJ8QrfqFA2cMU,2611
|
|
39
|
+
finbrain/endpoints/patent_filings.py,sha256=496m-3lnGc3twMAdcIDOyMWs3KwHD2W8c_vzOl7rI3I,2362
|
|
38
40
|
finbrain/endpoints/predictions.py,sha256=og1RY68p4O71smogrx-M5FyGk8FVclBGP18BvttzbZk,2026
|
|
39
41
|
finbrain/endpoints/recent.py,sha256=7D36oVRJKF5hkR42y6M6CiEMYksuFleZou1v86SbEfA,3004
|
|
40
42
|
finbrain/endpoints/reddit_mentions.py,sha256=WiePVAyUbA0VpWOd22M2YTlDWOQITp8qUBraI29y6d4,2305
|
|
41
|
-
finbrain/endpoints/screener.py,sha256=
|
|
43
|
+
finbrain/endpoints/screener.py,sha256=Ebw4Oksi8JNj1B8xX2Mh2U7MqO57telCpxx0rphPCe4,10166
|
|
42
44
|
finbrain/endpoints/senate_trades.py,sha256=K6r_-Wt1HOs0dDXngHdj6Eo_UUJXAy5yE4EmmQbGaiU,2226
|
|
43
45
|
finbrain/endpoints/sentiments.py,sha256=sGGPaxzGGFvMOaYv3VSCmnG7H9v7OVlX7Vsndct8jhA,2606
|
|
44
|
-
finbrain_python-0.2.
|
|
45
|
-
finbrain_python-0.2.
|
|
46
|
-
finbrain_python-0.2.
|
|
47
|
-
finbrain_python-0.2.
|
|
48
|
-
finbrain_python-0.2.
|
|
46
|
+
finbrain_python-0.2.5.dist-info/licenses/LICENSE,sha256=x71LjIUPbK7Y8YBulH_AXzEIX7BK90dgFL2zA6LBT-A,1069
|
|
47
|
+
finbrain_python-0.2.5.dist-info/METADATA,sha256=Oxu3uLLP90SHboG4mMl2fQoJLDsqUTgGgu5XhMqhebs,16763
|
|
48
|
+
finbrain_python-0.2.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
49
|
+
finbrain_python-0.2.5.dist-info/top_level.txt,sha256=dCiQtTx3z2TtO_yQI6q3yODcdb-pABB-iEReJegVAv0,9
|
|
50
|
+
finbrain_python-0.2.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|