catalejo 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.
- catalejo/__init__.py +10 -0
- catalejo/_http.py +203 -0
- catalejo/_parse.py +162 -0
- catalejo/documents.py +31 -0
- catalejo/filings.py +138 -0
- catalejo/mcp_server.py +63 -0
- catalejo/models.py +61 -0
- catalejo/py.typed +0 -0
- catalejo/securities.py +43 -0
- catalejo-0.1.0.dist-info/METADATA +148 -0
- catalejo-0.1.0.dist-info/RECORD +14 -0
- catalejo-0.1.0.dist-info/WHEEL +4 -0
- catalejo-0.1.0.dist-info/entry_points.txt +2 -0
- catalejo-0.1.0.dist-info/licenses/LICENSE +21 -0
catalejo/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""catalejo: Spanish CNMV public data, made usable."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from catalejo.documents import document_text
|
|
6
|
+
from catalejo.filings import filings
|
|
7
|
+
from catalejo.models import Filing, Security
|
|
8
|
+
from catalejo.securities import securities
|
|
9
|
+
|
|
10
|
+
__all__ = ["Filing", "Security", "document_text", "filings", "securities"]
|
catalejo/_http.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Polite HTTP client for cnmv.es.
|
|
2
|
+
|
|
3
|
+
Everything catalejo downloads goes through here, so the rules for being a
|
|
4
|
+
good citizen live in one place:
|
|
5
|
+
|
|
6
|
+
* identifiable User-Agent,
|
|
7
|
+
* at most one request per ``min_interval`` seconds,
|
|
8
|
+
* on-disk cache so repeated calls don't hit the CNMV again,
|
|
9
|
+
* a few retries with backoff on 429/5xx and network errors,
|
|
10
|
+
* only *.cnmv.es hosts are allowed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from catalejo import __version__
|
|
27
|
+
|
|
28
|
+
USER_AGENT = f"catalejo/{__version__} (+https://github.com/polfananas/catalejo)"
|
|
29
|
+
ALLOWED_HOST_SUFFIX = "cnmv.es"
|
|
30
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CNMVRequestError(RuntimeError):
|
|
34
|
+
"""Raised when the CNMV can't be reached or keeps answering with errors."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Response:
|
|
39
|
+
url: str
|
|
40
|
+
status_code: int
|
|
41
|
+
content: bytes
|
|
42
|
+
content_type: str
|
|
43
|
+
from_cache: bool = False
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def text(self) -> str:
|
|
47
|
+
return self.content.decode("utf-8", errors="replace")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_cache_dir() -> Path:
|
|
51
|
+
env = os.environ.get("CATALEJO_CACHE_DIR")
|
|
52
|
+
if env:
|
|
53
|
+
return Path(env)
|
|
54
|
+
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
55
|
+
return Path(base) / "catalejo"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HttpClient:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
cache_dir: Path | None = None,
|
|
63
|
+
cache_ttl: float = 3600.0,
|
|
64
|
+
min_interval: float = 1.0,
|
|
65
|
+
max_retries: int = 3,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
transport: httpx.BaseTransport | None = None,
|
|
68
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
69
|
+
clock: Callable[[], float] = time.monotonic,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.cache_dir = cache_dir if cache_dir is not None else default_cache_dir()
|
|
72
|
+
self.cache_ttl = cache_ttl
|
|
73
|
+
self.min_interval = min_interval
|
|
74
|
+
self.max_retries = max_retries
|
|
75
|
+
self._sleep = sleep
|
|
76
|
+
self._clock = clock
|
|
77
|
+
self._last_request: float | None = None
|
|
78
|
+
self._client = httpx.Client(
|
|
79
|
+
headers={"User-Agent": USER_AGENT},
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
follow_redirects=True,
|
|
82
|
+
transport=transport,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# -- public -----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def get(
|
|
88
|
+
self,
|
|
89
|
+
url: str,
|
|
90
|
+
params: Mapping[str, str | int] | None = None,
|
|
91
|
+
*,
|
|
92
|
+
use_cache: bool = True,
|
|
93
|
+
ttl: float | None = None,
|
|
94
|
+
) -> Response:
|
|
95
|
+
"""GET a cnmv.es URL. `ttl` overrides the client's cache_ttl for this call
|
|
96
|
+
(use `math.inf` for things that never change, like filed documents)."""
|
|
97
|
+
_check_host(url)
|
|
98
|
+
key = _cache_key(url, params)
|
|
99
|
+
ttl = self.cache_ttl if ttl is None else ttl
|
|
100
|
+
|
|
101
|
+
if use_cache and ttl > 0:
|
|
102
|
+
cached = self._read_cache(key, ttl)
|
|
103
|
+
if cached is not None:
|
|
104
|
+
return cached
|
|
105
|
+
|
|
106
|
+
response = self._fetch(url, params)
|
|
107
|
+
if use_cache and ttl > 0:
|
|
108
|
+
self._write_cache(key, response)
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
self._client.close()
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> HttpClient:
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc: object) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
# -- internals --------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _throttle(self) -> None:
|
|
123
|
+
if self._last_request is not None:
|
|
124
|
+
wait = self.min_interval - (self._clock() - self._last_request)
|
|
125
|
+
if wait > 0:
|
|
126
|
+
self._sleep(wait)
|
|
127
|
+
self._last_request = self._clock()
|
|
128
|
+
|
|
129
|
+
def _fetch(self, url: str, params: Mapping[str, str | int] | None) -> Response:
|
|
130
|
+
last_error: str = ""
|
|
131
|
+
for attempt in range(self.max_retries + 1):
|
|
132
|
+
if attempt:
|
|
133
|
+
self._sleep(min(2**attempt, 30))
|
|
134
|
+
self._throttle()
|
|
135
|
+
try:
|
|
136
|
+
r = self._client.get(url, params=dict(params) if params else None)
|
|
137
|
+
except httpx.TransportError as e:
|
|
138
|
+
last_error = f"{type(e).__name__}: {e}"
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if r.status_code in RETRY_STATUSES:
|
|
142
|
+
last_error = f"HTTP {r.status_code}"
|
|
143
|
+
retry_after = r.headers.get("Retry-After", "")
|
|
144
|
+
if retry_after.isdigit():
|
|
145
|
+
self._sleep(min(int(retry_after), 60))
|
|
146
|
+
continue
|
|
147
|
+
if r.status_code >= 400:
|
|
148
|
+
raise CNMVRequestError(f"HTTP {r.status_code} for {r.url}")
|
|
149
|
+
|
|
150
|
+
return Response(
|
|
151
|
+
url=str(r.url),
|
|
152
|
+
status_code=r.status_code,
|
|
153
|
+
content=r.content,
|
|
154
|
+
content_type=r.headers.get("Content-Type", ""),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
raise CNMVRequestError(
|
|
158
|
+
f"giving up on {url} after {self.max_retries + 1} attempts ({last_error})"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _read_cache(self, key: str, ttl: float) -> Response | None:
|
|
162
|
+
meta_path = self.cache_dir / f"{key}.json"
|
|
163
|
+
body_path = self.cache_dir / f"{key}.bin"
|
|
164
|
+
try:
|
|
165
|
+
meta = json.loads(meta_path.read_text())
|
|
166
|
+
if time.time() - meta["fetched_at"] > ttl:
|
|
167
|
+
return None
|
|
168
|
+
content = body_path.read_bytes()
|
|
169
|
+
except (OSError, ValueError, KeyError):
|
|
170
|
+
return None
|
|
171
|
+
return Response(
|
|
172
|
+
url=meta["url"],
|
|
173
|
+
status_code=meta["status_code"],
|
|
174
|
+
content=content,
|
|
175
|
+
content_type=meta["content_type"],
|
|
176
|
+
from_cache=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _write_cache(self, key: str, response: Response) -> None:
|
|
180
|
+
try:
|
|
181
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
(self.cache_dir / f"{key}.bin").write_bytes(response.content)
|
|
183
|
+
meta = {
|
|
184
|
+
"url": response.url,
|
|
185
|
+
"status_code": response.status_code,
|
|
186
|
+
"content_type": response.content_type,
|
|
187
|
+
"fetched_at": time.time(),
|
|
188
|
+
}
|
|
189
|
+
(self.cache_dir / f"{key}.json").write_text(json.dumps(meta))
|
|
190
|
+
except OSError:
|
|
191
|
+
pass # a broken cache shouldn't break the request
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_host(url: str) -> None:
|
|
195
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
196
|
+
if host != ALLOWED_HOST_SUFFIX and not host.endswith("." + ALLOWED_HOST_SUFFIX):
|
|
197
|
+
raise ValueError(f"catalejo only talks to cnmv.es, got {host!r}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cache_key(url: str, params: Mapping[str, str | int] | None) -> str:
|
|
201
|
+
items = sorted((k, str(v)) for k, v in (params or {}).items())
|
|
202
|
+
raw = url + "?" + "&".join(f"{k}={v}" for k, v in items)
|
|
203
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
catalejo/_parse.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""HTML parsing for CNMV result listings (IP and OIR share the same markup)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from selectolax.lexbor import LexborHTMLParser as HTMLParser
|
|
12
|
+
from selectolax.lexbor import LexborNode as Node
|
|
13
|
+
|
|
14
|
+
from catalejo.models import Filing, FilingKind, Security
|
|
15
|
+
|
|
16
|
+
MADRID = ZoneInfo("Europe/Madrid")
|
|
17
|
+
|
|
18
|
+
_PAGE_INFO = re.compile(r"(\d+)\D+(\d+)") # "Página 1 de 8" / "Page 1 out of 8"
|
|
19
|
+
_REGISTRY = re.compile(r"(\d+)\s*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CNMVParseError(ValueError):
|
|
23
|
+
"""The page didn't look like we expected. Probably the CNMV changed its HTML."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ResultsPage:
|
|
28
|
+
filings: list[Filing]
|
|
29
|
+
page: int
|
|
30
|
+
"""1-based number of this page."""
|
|
31
|
+
total_pages: int
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_last(self) -> bool:
|
|
35
|
+
return self.page >= self.total_pages
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_results_page(html: str, kind: FilingKind) -> ResultsPage:
|
|
39
|
+
tree = HTMLParser(html)
|
|
40
|
+
filings = [_parse_entry(node, kind) for node in tree.css("#listaElementosPrimernivel > li")]
|
|
41
|
+
page, total = _parse_page_info(tree)
|
|
42
|
+
return ResultsPage(filings=filings, page=page, total_pages=total)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_nif(raw: str) -> str:
|
|
46
|
+
return re.sub(r"[\s\-.]", "", raw).upper()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_entry(node: Node, kind: FilingKind) -> Filing:
|
|
50
|
+
date = _text(node, "li.fecha-con-hora")
|
|
51
|
+
time = _text(node, "li.time")
|
|
52
|
+
issuer_link = _required(node, "a[id$=hlTituloCabecera]")
|
|
53
|
+
doc_link = _required(node, "a[id$=subtituloRegistroEnlace]")
|
|
54
|
+
|
|
55
|
+
registry_text = next(
|
|
56
|
+
(li.text(strip=True) for li in node.css("li.resumen") if "registro" in li.text().lower()),
|
|
57
|
+
"",
|
|
58
|
+
)
|
|
59
|
+
registry = _REGISTRY.search(registry_text)
|
|
60
|
+
if registry is None:
|
|
61
|
+
raise CNMVParseError(f"no registry number in entry: {node.text(strip=True)[:120]!r}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
registered_at = datetime.strptime(f"{date} {time}", "%d/%m/%Y %H:%M").replace(tzinfo=MADRID)
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
raise CNMVParseError(f"bad date/time {date!r} {time!r}") from e
|
|
67
|
+
|
|
68
|
+
raw_nif = _nif_from_href(issuer_link.attributes.get("href") or "")
|
|
69
|
+
return Filing(
|
|
70
|
+
kind=kind,
|
|
71
|
+
registered_at=registered_at,
|
|
72
|
+
issuer=issuer_link.text(strip=True),
|
|
73
|
+
issuer_nif=normalize_nif(raw_nif) if raw_nif else None,
|
|
74
|
+
category=_text(node, "span[id$=descripcionSubtituloCabecera]"),
|
|
75
|
+
title=doc_link.text(strip=True),
|
|
76
|
+
registry_number=int(registry.group(1)),
|
|
77
|
+
document_url=(doc_link.attributes.get("href") or "").strip(),
|
|
78
|
+
issuer_nif_raw=raw_nif,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_page_info(tree: HTMLParser) -> tuple[int, int]:
|
|
83
|
+
info = tree.css_first(".PagActivaTXT")
|
|
84
|
+
if info is None:
|
|
85
|
+
return 1, 1 # single page of results: the CNMV shows no paginator
|
|
86
|
+
match = _PAGE_INFO.search(info.text())
|
|
87
|
+
if match is None:
|
|
88
|
+
raise CNMVParseError(f"can't read pagination from {info.text()!r}")
|
|
89
|
+
return int(match.group(1)), int(match.group(2))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _nif_from_href(href: str) -> str | None:
|
|
93
|
+
match = re.search(r"[?&]nif=([^&]+)", href, flags=re.IGNORECASE)
|
|
94
|
+
return unquote(match.group(1)).strip() if match else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _required(node: Node, selector: str) -> Node:
|
|
98
|
+
found = node.css_first(selector)
|
|
99
|
+
if found is None:
|
|
100
|
+
raise CNMVParseError(f"missing {selector!r} in entry: {node.text(strip=True)[:120]!r}")
|
|
101
|
+
return found
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _text(node: Node, selector: str) -> str:
|
|
105
|
+
return _required(node, selector).text(strip=True)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class SecuritiesPage:
|
|
110
|
+
securities: list[Security]
|
|
111
|
+
page: int
|
|
112
|
+
total_pages: int
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_last(self) -> bool:
|
|
116
|
+
return self.page >= self.total_pages
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_securities_page(html: str) -> SecuritiesPage:
|
|
120
|
+
tree = HTMLParser(html)
|
|
121
|
+
securities = []
|
|
122
|
+
for row in tree.css("table#ctl00_ContentPrincipal_grdDatos tbody tr"):
|
|
123
|
+
cells = {
|
|
124
|
+
(td.attributes.get("data-th") or ""): td.text().strip() or None for td in row.css("td")
|
|
125
|
+
}
|
|
126
|
+
isin = cells.get("ISIN")
|
|
127
|
+
if not isin:
|
|
128
|
+
raise CNMVParseError(f"ISIN row without ISIN: {row.text(strip=True)[:120]!r}")
|
|
129
|
+
securities.append(
|
|
130
|
+
Security(
|
|
131
|
+
isin=isin,
|
|
132
|
+
fisn=cells.get("FISN"),
|
|
133
|
+
cfi=cells.get("CFI 2015"),
|
|
134
|
+
currency=cells.get("Moneda"),
|
|
135
|
+
issue_date=_es_date(cells.get("Fecha emisión")),
|
|
136
|
+
maturity_date=_es_date(cells.get("Fecha vencimiento")),
|
|
137
|
+
outstanding=_es_number(cells.get("Nº valores / Saldo emitido")),
|
|
138
|
+
nominal=_es_number(cells.get("Nominal / VMN / Precio")),
|
|
139
|
+
interest_rate=_es_number(cells.get("Interés")),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
page, total = _parse_page_info(tree)
|
|
143
|
+
return SecuritiesPage(securities=securities, page=page, total_pages=total)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _es_number(text: str | None) -> float | None:
|
|
147
|
+
""" "4.768.462.486,00" -> 4768462486.0"""
|
|
148
|
+
if not text:
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
return float(text.replace(".", "").replace(",", "."))
|
|
152
|
+
except ValueError as e:
|
|
153
|
+
raise CNMVParseError(f"bad number {text!r}") from e
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _es_date(text: str | None) -> date | None:
|
|
157
|
+
if not text:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return datetime.strptime(text, "%d/%m/%Y").date()
|
|
161
|
+
except ValueError as e:
|
|
162
|
+
raise CNMVParseError(f"bad date {text!r}") from e
|
catalejo/documents.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Text of the documents attached to filings (PDFs)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from catalejo._http import HttpClient
|
|
9
|
+
from catalejo.filings import _default_client
|
|
10
|
+
from catalejo.models import Filing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def document_text(filing: Filing | str, *, client: HttpClient | None = None) -> str:
|
|
14
|
+
"""Plain text of a filing's PDF (or of a document URL).
|
|
15
|
+
|
|
16
|
+
Needs the optional dependency: ``pip install "catalejo[pdf]"``.
|
|
17
|
+
Documents never change, so they're cached forever after the first download.
|
|
18
|
+
Scanned PDFs have no text layer and return "".
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
from pypdf import PdfReader
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError('document_text() needs pypdf: pip install "catalejo[pdf]"') from None
|
|
24
|
+
|
|
25
|
+
url = filing.document_url if isinstance(filing, Filing) else filing
|
|
26
|
+
response = (client or _default_client()).get(url, ttl=math.inf)
|
|
27
|
+
if not response.content.startswith(b"%PDF"):
|
|
28
|
+
raise ValueError(f"not a PDF ({response.content_type or 'unknown type'}): {url}")
|
|
29
|
+
|
|
30
|
+
reader = PdfReader(io.BytesIO(response.content))
|
|
31
|
+
return "\n".join(page.extract_text() or "" for page in reader.pages).strip()
|
catalejo/filings.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Price-sensitive (IP) and other regulatory (OIR) announcements published by issuers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
import warnings
|
|
8
|
+
from datetime import date, datetime, timedelta
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from catalejo._http import HttpClient
|
|
12
|
+
from catalejo._parse import MADRID, normalize_nif, parse_results_page
|
|
13
|
+
from catalejo.models import Filing, FilingKind
|
|
14
|
+
|
|
15
|
+
BASE = "https://www.cnmv.es/portal"
|
|
16
|
+
RESULT_URLS: dict[FilingKind, str] = {
|
|
17
|
+
"IP": f"{BASE}/Informacion-Privilegiada/Resultado-IP",
|
|
18
|
+
"OIR": f"{BASE}/Otra-Informacion-Relevante/Resultado-OIR",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Spanish NIF/CIF shapes: B12345678, A1234567B, 12345678Z (foreign issuers: N1234567X)
|
|
22
|
+
MAX_DAYS = 90 # the CNMV answers HTTP 400 above ~91; longer windows go as a date range
|
|
23
|
+
|
|
24
|
+
_NIF = re.compile(r"^(?:[A-Z]\d{7}[A-Z0-9]|\d{8}[A-Z])$")
|
|
25
|
+
|
|
26
|
+
_client: HttpClient | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_client() -> HttpClient:
|
|
30
|
+
global _client
|
|
31
|
+
if _client is None:
|
|
32
|
+
_client = HttpClient()
|
|
33
|
+
return _client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def filings(
|
|
37
|
+
days: int = 7,
|
|
38
|
+
kind: FilingKind | Literal["all"] = "all",
|
|
39
|
+
issuer: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
since: date | None = None,
|
|
42
|
+
until: date | None = None,
|
|
43
|
+
max_pages: int = 50,
|
|
44
|
+
client: HttpClient | None = None,
|
|
45
|
+
) -> list[Filing]:
|
|
46
|
+
"""Filings registered at the CNMV, newest first.
|
|
47
|
+
|
|
48
|
+
By default the last `days` days. Pass `since` (and optionally `until`) for a
|
|
49
|
+
fixed date range instead, e.g. to pull history.
|
|
50
|
+
|
|
51
|
+
>>> import catalejo
|
|
52
|
+
>>> catalejo.filings(days=3, kind="IP") # doctest: +SKIP
|
|
53
|
+
>>> catalejo.filings(since=date(2024, 1, 1), issuer="A64907306") # doctest: +SKIP
|
|
54
|
+
"""
|
|
55
|
+
if days < 1:
|
|
56
|
+
raise ValueError("days must be >= 1")
|
|
57
|
+
window = _window(days, since, until)
|
|
58
|
+
client = client or _default_client()
|
|
59
|
+
kinds: list[FilingKind] = ["IP", "OIR"] if kind == "all" else [kind]
|
|
60
|
+
|
|
61
|
+
nif = _as_nif(issuer) if issuer is not None else None
|
|
62
|
+
|
|
63
|
+
result: list[Filing] = []
|
|
64
|
+
for k in kinds:
|
|
65
|
+
if nif:
|
|
66
|
+
# Ask the CNMV for just this issuer. It only matches the NIF in the exact
|
|
67
|
+
# form it stores it, and that can have a dash or not, so try both.
|
|
68
|
+
seen = set()
|
|
69
|
+
for variant in (nif, f"{nif[0]}-{nif[1:]}"):
|
|
70
|
+
for f in _fetch_all_pages(client, k, {**window, "nif": variant}, max_pages):
|
|
71
|
+
if f.registry_number not in seen:
|
|
72
|
+
seen.add(f.registry_number)
|
|
73
|
+
result.append(f)
|
|
74
|
+
else:
|
|
75
|
+
result.extend(_fetch_all_pages(client, k, window, max_pages))
|
|
76
|
+
|
|
77
|
+
if issuer is not None:
|
|
78
|
+
result = [f for f in result if matches_issuer(f, issuer)]
|
|
79
|
+
|
|
80
|
+
result.sort(key=lambda f: f.registered_at, reverse=True)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fetch_all_pages(
|
|
85
|
+
client: HttpClient, kind: FilingKind, params: dict[str, str | int], max_pages: int
|
|
86
|
+
) -> list[Filing]:
|
|
87
|
+
out: list[Filing] = []
|
|
88
|
+
for page_index in range(max_pages): # the CNMV's `page` param is zero-based
|
|
89
|
+
response = client.get(RESULT_URLS[kind], {**params, "page": page_index})
|
|
90
|
+
page = parse_results_page(response.text, kind)
|
|
91
|
+
out.extend(page.filings)
|
|
92
|
+
if page.is_last or not page.filings:
|
|
93
|
+
break
|
|
94
|
+
else:
|
|
95
|
+
warnings.warn(
|
|
96
|
+
f"stopped after max_pages={max_pages}; results are incomplete. "
|
|
97
|
+
"Narrow the date range or raise max_pages.",
|
|
98
|
+
stacklevel=3,
|
|
99
|
+
)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _window(days: int, since: date | None, until: date | None) -> dict[str, str | int]:
|
|
104
|
+
"""Query params for the time window: `dias=N` when possible, a date range otherwise."""
|
|
105
|
+
if since is None and until is None and days <= MAX_DAYS:
|
|
106
|
+
return {"dias": days}
|
|
107
|
+
today = datetime.now(MADRID).date()
|
|
108
|
+
until = until or today
|
|
109
|
+
since = since or until - timedelta(days=days)
|
|
110
|
+
if since > until:
|
|
111
|
+
raise ValueError(f"since ({since}) is after until ({until})")
|
|
112
|
+
return {"fechaDesde": f"{since:%d/%m/%Y}", "fechaHasta": f"{until:%d/%m/%Y}"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _as_nif(query: str) -> str | None:
|
|
116
|
+
"""The query normalized as a NIF, or None if it doesn't look like one."""
|
|
117
|
+
candidate = normalize_nif(query)
|
|
118
|
+
return candidate if _NIF.match(candidate) else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def matches_issuer(filing: Filing, query: str) -> bool:
|
|
122
|
+
"""True if `query` identifies the filing's issuer.
|
|
123
|
+
|
|
124
|
+
`query` can be a NIF ("A28004885", "A-28004885") or part of the name
|
|
125
|
+
("cellnex", "Telefónica" == "telefonica").
|
|
126
|
+
"""
|
|
127
|
+
q = query.strip()
|
|
128
|
+
if not q:
|
|
129
|
+
raise ValueError("issuer can't be empty")
|
|
130
|
+
if filing.issuer_nif and normalize_nif(q) == filing.issuer_nif:
|
|
131
|
+
return True
|
|
132
|
+
return _fold(q) in _fold(filing.issuer)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _fold(text: str) -> str:
|
|
136
|
+
"""Lowercase and strip accents, so "Telefónica" == "telefonica"."""
|
|
137
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
138
|
+
return "".join(c for c in decomposed if not unicodedata.combining(c)).casefold()
|
catalejo/mcp_server.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""MCP server exposing CNMV filings to AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import date
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
import catalejo
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP("catalejo")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_dict(f: catalejo.Filing) -> dict[str, Any]:
|
|
18
|
+
d = dataclasses.asdict(f)
|
|
19
|
+
d["registered_at"] = f.registered_at.isoformat() # datetime isn't JSON
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
def latest_filings(
|
|
25
|
+
days: int = 3, kind: Literal["IP", "OIR", "all"] = "all"
|
|
26
|
+
) -> list[dict[str, Any]]:
|
|
27
|
+
"""Latest filings published at the Spanish CNMV, newest first.
|
|
28
|
+
|
|
29
|
+
kind: "IP" = price-sensitive announcements, "OIR" = other regulatory announcements.
|
|
30
|
+
"""
|
|
31
|
+
return [_as_dict(f) for f in catalejo.filings(days=days, kind=kind)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
def issuer_filings(
|
|
36
|
+
issuer: str,
|
|
37
|
+
days: int = 7,
|
|
38
|
+
kind: Literal["IP", "OIR", "all"] = "all",
|
|
39
|
+
since: date | None = None,
|
|
40
|
+
until: date | None = None,
|
|
41
|
+
limit: int = 50,
|
|
42
|
+
) -> list[dict[str, Any]]:
|
|
43
|
+
"""Filings from one specific company at the Spanish CNMV, newest first.
|
|
44
|
+
|
|
45
|
+
issuer: its Spanish tax id / NIF ("A64907306") is best: the CNMV filters by it
|
|
46
|
+
directly, so even years of history come back in seconds. A company name or part
|
|
47
|
+
of it ("Cellnex", "telefonica") also works but scans every filing, so keep
|
|
48
|
+
`days` small (7-14) when searching by name.
|
|
49
|
+
since / until: optional date range (YYYY-MM-DD) for history; overrides `days`.
|
|
50
|
+
Price-sensitive announcements (IP) exist from Feb 2020 onwards.
|
|
51
|
+
limit: max filings returned (the newest ones).
|
|
52
|
+
"""
|
|
53
|
+
found = catalejo.filings(days=days, kind=kind, issuer=issuer, since=since, until=until)
|
|
54
|
+
return [_as_dict(f) for f in found[:limit]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # one INFO line per request is noise
|
|
59
|
+
mcp.run() # stdio by default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
catalejo/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Data models returned by catalejo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
from urllib.parse import parse_qs, urlsplit
|
|
9
|
+
|
|
10
|
+
FilingKind = Literal["IP", "OIR"]
|
|
11
|
+
"""``IP`` = información privilegiada (inside information, art. 17 MAR).
|
|
12
|
+
``OIR`` = otra información relevante (other relevant information)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Filing:
|
|
17
|
+
"""One disclosure published by an issuer through the CNMV."""
|
|
18
|
+
|
|
19
|
+
kind: FilingKind
|
|
20
|
+
registered_at: datetime
|
|
21
|
+
"""When the CNMV registered it (timezone-aware, Europe/Madrid)."""
|
|
22
|
+
issuer: str
|
|
23
|
+
issuer_nif: str | None
|
|
24
|
+
"""Spanish tax id of the issuer, without dashes. Foreign issuers get an N-prefixed NIF."""
|
|
25
|
+
category: str
|
|
26
|
+
"""Category as shown by the CNMV, in Spanish (e.g. "Sobre gobierno corporativo")."""
|
|
27
|
+
title: str
|
|
28
|
+
registry_number: int
|
|
29
|
+
"""Official registry number. IP and OIR are numbered separately."""
|
|
30
|
+
document_url: str
|
|
31
|
+
"""Link to the filed document (usually a PDF)."""
|
|
32
|
+
issuer_nif_raw: str | None = None
|
|
33
|
+
"""NIF exactly as the CNMV writes it (sometimes "A-28004885"). Needed to query by issuer."""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def document_id(self) -> str | None:
|
|
37
|
+
"""The GUID the CNMV uses to identify the document, if present in the URL."""
|
|
38
|
+
values = parse_qs(urlsplit(self.document_url).query).get("t")
|
|
39
|
+
return values[0].strip("{}") if values else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Security:
|
|
44
|
+
"""A security registered under an issuer at the CNMV's ISIN agency (ANCV)."""
|
|
45
|
+
|
|
46
|
+
isin: str
|
|
47
|
+
fisn: str | None
|
|
48
|
+
"""Short name, e.g. "CLNX/AC 0.25" (AC = acciones / shares, OB = bonds)."""
|
|
49
|
+
cfi: str | None
|
|
50
|
+
"""ISO 10962 classification. Shares start with "ES". Empty for most foreign issuers."""
|
|
51
|
+
currency: str | None = None
|
|
52
|
+
issue_date: date | None = None
|
|
53
|
+
maturity_date: date | None = None
|
|
54
|
+
outstanding: float | None = None
|
|
55
|
+
"""Number of securities (shares) or amount issued (debt)."""
|
|
56
|
+
nominal: float | None = None
|
|
57
|
+
interest_rate: float | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_share(self) -> bool:
|
|
61
|
+
return (self.cfi or "").startswith("ES")
|
catalejo/py.typed
ADDED
|
File without changes
|
catalejo/securities.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Securities (ISINs) registered under an issuer, from the CNMV's ISIN agency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from catalejo._http import HttpClient
|
|
6
|
+
from catalejo._parse import normalize_nif, parse_securities_page
|
|
7
|
+
from catalejo.filings import _default_client
|
|
8
|
+
from catalejo.models import Security
|
|
9
|
+
|
|
10
|
+
ISIN_URL = "https://www.cnmv.es/portal/ancv/isin.aspx"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def securities(
|
|
14
|
+
nif: str, *, client: HttpClient | None = None, max_pages: int = 50
|
|
15
|
+
) -> list[Security]:
|
|
16
|
+
"""All ISINs the CNMV lists for an issuer: shares, bonds, notes...
|
|
17
|
+
|
|
18
|
+
`nif` can be written with or without dash. Use `[s for s in ... if s.is_share]`
|
|
19
|
+
to keep only shares.
|
|
20
|
+
|
|
21
|
+
>>> catalejo.securities("A64907306") # doctest: +SKIP
|
|
22
|
+
[Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
|
|
23
|
+
"""
|
|
24
|
+
client = client or _default_client()
|
|
25
|
+
base = normalize_nif(nif)
|
|
26
|
+
if not base:
|
|
27
|
+
raise ValueError("nif can't be empty")
|
|
28
|
+
# Like the filings search, the CNMV only matches the NIF in the form it stores it.
|
|
29
|
+
for variant in (base, f"{base[0]}-{base[1:]}"):
|
|
30
|
+
found = _fetch(client, variant, max_pages)
|
|
31
|
+
if found:
|
|
32
|
+
return found
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fetch(client: HttpClient, nif: str, max_pages: int) -> list[Security]:
|
|
37
|
+
out: list[Security] = []
|
|
38
|
+
for page_index in range(max_pages):
|
|
39
|
+
page = parse_securities_page(client.get(ISIN_URL, {"nif": nif, "page": page_index}).text)
|
|
40
|
+
out.extend(page.securities)
|
|
41
|
+
if page.is_last or not page.securities:
|
|
42
|
+
break
|
|
43
|
+
return out
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: catalejo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clean, structured access to Spanish securities regulator (CNMV) public data, as a Python library and MCP server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/polfananas/catalejo
|
|
6
|
+
Project-URL: Issues, https://github.com/polfananas/catalejo/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/polfananas/catalejo/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Pol Fañanás
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cnmv,corporate-disclosures,edgar,finance,financial-data,mcp,securities,spain
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: selectolax>=1.0
|
|
21
|
+
Requires-Dist: tzdata; sys_platform == 'win32'
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pypdf>=5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Provides-Extra: mcp
|
|
28
|
+
Requires-Dist: mcp<2,>=1.20; extra == 'mcp'
|
|
29
|
+
Provides-Extra: pdf
|
|
30
|
+
Requires-Dist: pypdf>=5; extra == 'pdf'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# catalejo
|
|
34
|
+
|
|
35
|
+
[](https://github.com/polfananas/catalejo/actions/workflows/tests.yml)
|
|
36
|
+
|
|
37
|
+
> *catalejo* (Spanish): spyglass.
|
|
38
|
+
|
|
39
|
+
**Spain's securities regulator (CNMV) publishes everything listed companies have to disclose, but only through web forms and PDFs.** catalejo turns that into clean Python objects, and plugs it into AI agents through an MCP server.
|
|
40
|
+
|
|
41
|
+
Think of it as a small **SEC EDGAR for Spain**.
|
|
42
|
+
|
|
43
|
+
> *Having had perfect understanding of all things from the very first, to write unto thee in order, that thou mightest know the certainty of those things.* (Luke 1:3-4)
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
<!-- TODO: second GIF, Claude Desktop using the MCP server -->
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
>>> import catalejo
|
|
51
|
+
>>> for f in catalejo.filings(days=7, kind="IP"):
|
|
52
|
+
... print(f.registered_at, f.issuer, "|", f.title)
|
|
53
|
+
2026-11-10 16:20:00+01:00 CONSTRUCCIONES Y AUXILIAR DE FERROCARRILES, S.A | El Consejo de Administración de la Sociedad ha aprobado el Plan Estratégico 2027-2030.
|
|
54
|
+
2026-11-09 12:14:00+01:00 ELECNOR, S.A. | Elecnor comunica la propuesta del Consejo de Administración de distribución de un dividendo ...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install catalejo # library
|
|
61
|
+
pip install "catalejo[mcp]" # + MCP server for Claude and other agents
|
|
62
|
+
pip install "catalejo[pdf]" # + text extraction from filed PDFs
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Python 3.10+.
|
|
66
|
+
|
|
67
|
+
## What you can get
|
|
68
|
+
|
|
69
|
+
**Price-sensitive announcements (`IP`) and other regulatory announcements (`OIR`)**, the two channels issuers use to disclose anything that can move the price: results, M&A, takeover bids, guidance, board changes, buybacks...
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from datetime import date
|
|
73
|
+
import catalejo
|
|
74
|
+
|
|
75
|
+
catalejo.filings(days=3) # everything from the last 3 days, IP + OIR
|
|
76
|
+
catalejo.filings(days=30, kind="IP", issuer="cellnex")
|
|
77
|
+
|
|
78
|
+
# Full history for one company. Filtering by NIF (Spanish tax id) is done
|
|
79
|
+
# by the CNMV itself, so years of data come back in a few seconds.
|
|
80
|
+
catalejo.filings(since=date(2020, 1, 1), kind="IP", issuer="A64907306")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Each `Filing` has `kind`, `registered_at` (timezone-aware, Madrid), `issuer`, `issuer_nif`, `category`, `title`, `registry_number` and `document_url`.
|
|
84
|
+
|
|
85
|
+
**The text of the filed document:**
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
f = catalejo.filings(days=10, kind="IP", issuer="A64907306")[0]
|
|
89
|
+
print(catalejo.document_text(f)) # needs catalejo[pdf]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**The securities (ISINs) of an issuer**, shares and bonds:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
>>> [s for s in catalejo.securities("A64907306") if s.is_share]
|
|
96
|
+
[Security(isin='ES0105066007', fisn='CLNX/AC 0.25', cfi='ESVUFB', ...)]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Use it from Claude (MCP)
|
|
100
|
+
|
|
101
|
+
catalejo ships an MCP server with two tools, `latest_filings` and `issuer_filings`, so you can ask things like *"What price-sensitive news has Cellnex published since 2023? Group it by topic."*
|
|
102
|
+
|
|
103
|
+
**Claude Desktop:** add this to `claude_desktop_config.json` (Settings → Developer → Edit Config) and restart the app:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"catalejo": { "command": "catalejo-mcp" }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If Claude can't find the command, use the full path from `which catalejo-mcp`.
|
|
114
|
+
|
|
115
|
+
**Claude Code:** `claude mcp add catalejo -- catalejo-mcp`
|
|
116
|
+
|
|
117
|
+
## Why not just ask an AI with web search?
|
|
118
|
+
|
|
119
|
+
Because the CNMV's search pages are forms that search engines barely index. An agent with web search ends up reading press articles: incomplete, late, and you can't tell what's missing. With catalejo it reads the official register directly: every filing, with its exact time, registry number and a link to the original document. And it can do what web search can't, like going through years of history or across every issuer.
|
|
120
|
+
|
|
121
|
+
## Being a good citizen
|
|
122
|
+
|
|
123
|
+
catalejo talks only to `cnmv.es`, sends an identifiable User-Agent, makes **at most one request per second** and caches responses on disk (`~/.cache/catalejo`, or set `CATALEJO_CACHE_DIR`). Filed documents never change, so they are downloaded once. Please keep it that way if you fork it.
|
|
124
|
+
|
|
125
|
+
## Good to know
|
|
126
|
+
|
|
127
|
+
- **Price-sensitive announcements (`IP`) have their own channel since 8 February 2020.** Before that, everything was published as *hechos relevantes*, which catalejo doesn't cover yet.
|
|
128
|
+
- Searching by **NIF** is fast (the CNMV filters). Searching by **name** works, but scans every filing in the window, so keep the window short.
|
|
129
|
+
- Times are the CNMV's registration time, to the minute.
|
|
130
|
+
- Filings come in the language the issuer used, which is almost always Spanish.
|
|
131
|
+
- Early days: the API may still change before 1.0.
|
|
132
|
+
|
|
133
|
+
## Roadmap
|
|
134
|
+
|
|
135
|
+
- [x] Price-sensitive and other regulatory announcements, with full history
|
|
136
|
+
- [x] ISINs per issuer, text of filed PDFs
|
|
137
|
+
- [x] MCP server
|
|
138
|
+
- [ ] Directors' and managers' share dealings (art. 19 MAR)
|
|
139
|
+
- [ ] Net short positions
|
|
140
|
+
- [ ] Takeover bids (OPAs): status, price, spread
|
|
141
|
+
- [ ] Fund portfolios
|
|
142
|
+
- [ ] CLI
|
|
143
|
+
|
|
144
|
+
## Data and license
|
|
145
|
+
|
|
146
|
+
The data comes from the [CNMV](https://www.cnmv.es) public registers and remains subject to the CNMV's [legal notice](https://www.cnmv.es/Portal/utilidades/NotaLegal): reproduce it faithfully, and if you ever sell it, tell your buyers it's free at cnmv.es. catalejo is not affiliated with or endorsed by the CNMV.
|
|
147
|
+
|
|
148
|
+
Code: MIT. See [LICENSE](https://github.com/polfananas/catalejo/blob/main/LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
catalejo/__init__.py,sha256=qsAtDLoXw2Dd7qcbUriAVETKXaZoc1dZ3pr9n-ssZ28,325
|
|
2
|
+
catalejo/_http.py,sha256=fP9bLKfR4SJSv_PEED6c53wQLXJgV_0vBKs_UThzZtg,6692
|
|
3
|
+
catalejo/_parse.py,sha256=pZqOnamHZxGyaefXc6cWlIWAv5qlBcZwWrBpst76mcs,5462
|
|
4
|
+
catalejo/documents.py,sha256=JWP-5GKSi_eh1RZmSG3Eqy5DyawAuTf2kWXVDz7z6sM,1169
|
|
5
|
+
catalejo/filings.py,sha256=jJghACO3bgSLZWM8PPbfIBBph6PCOMZsSELbgW9fD_8,4896
|
|
6
|
+
catalejo/mcp_server.py,sha256=kgBFtL70ZBVI2YjRvQU5COfaNqD2Y0oMhmJYWIvSv7U,1937
|
|
7
|
+
catalejo/models.py,sha256=vGI0nHL-Z7CrXfEObPF3xUH-VnvP39zPRqa78tLgWM4,2212
|
|
8
|
+
catalejo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
catalejo/securities.py,sha256=FuQbs0AvmWlTMYDimxdm5JRg4u5MbdY7M9IqOyueUEw,1539
|
|
10
|
+
catalejo-0.1.0.dist-info/METADATA,sha256=5fC6LjI8JCjdTvODK19g71NdzVJlCoIwEYTSIJvA478,6831
|
|
11
|
+
catalejo-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
catalejo-0.1.0.dist-info/entry_points.txt,sha256=rHRSXqxtYzYpLmRGqyGH_Ac859PpXzgAm5G1a432VbE,58
|
|
13
|
+
catalejo-0.1.0.dist-info/licenses/LICENSE,sha256=3Yga6SazeCMa1lYyNhcvRtg7-q3Nw2AFh-4Xk8RGfy8,1070
|
|
14
|
+
catalejo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pol Fañanás
|
|
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.
|