fundly 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.
- fundly/__init__.py +70 -0
- fundly/_version.py +1 -0
- fundly/cache.py +227 -0
- fundly/client.py +648 -0
- fundly/currency.py +25 -0
- fundly/errors.py +22 -0
- fundly/fx.py +121 -0
- fundly/ids.py +84 -0
- fundly/models.py +118 -0
- fundly/quality.py +55 -0
- fundly/search.py +73 -0
- fundly/sources/__init__.py +0 -0
- fundly/sources/argentina.py +233 -0
- fundly/sources/base.py +237 -0
- fundly/sources/brazil.py +203 -0
- fundly/sources/chile.py +114 -0
- fundly/sources/china.py +96 -0
- fundly/sources/colombia.py +106 -0
- fundly/sources/comparadorfondos.py +62 -0
- fundly/sources/finect.py +159 -0
- fundly/sources/ft.py +84 -0
- fundly/sources/id_managers.py +171 -0
- fundly/sources/india.py +192 -0
- fundly/sources/indonesia.py +176 -0
- fundly/sources/israel.py +110 -0
- fundly/sources/morningstar.py +102 -0
- fundly/sources/poland.py +61 -0
- fundly/sources/quefondos.py +34 -0
- fundly/sources/vietnam.py +103 -0
- fundly/sources/yahoo.py +80 -0
- fundly-0.1.0.dist-info/METADATA +325 -0
- fundly-0.1.0.dist-info/RECORD +34 -0
- fundly-0.1.0.dist-info/WHEEL +4 -0
- fundly-0.1.0.dist-info/licenses/LICENSE +75 -0
fundly/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Prices and search for European funds, ETFs and Spanish pension plans, keyed by ISIN or DGS code.
|
|
2
|
+
|
|
3
|
+
import fundly
|
|
4
|
+
fundly.search("azvalor")
|
|
5
|
+
fundly.quote("ES0112611001")
|
|
6
|
+
fundly.history("N5508", start="2024-01-01").to_pandas()
|
|
7
|
+
fundly.compare("IE00B4L5Y983")
|
|
8
|
+
|
|
9
|
+
The module-level functions share one default Client; create your own Client for custom settings.
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .client import ORDER, Client
|
|
15
|
+
from .errors import FundlyError, FundlyTimeout, FXUnavailable, InvalidId, NotFound, UnknownSource
|
|
16
|
+
from .models import Comparison, History, Instrument, PricePoint, Quote
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ORDER",
|
|
20
|
+
"Client",
|
|
21
|
+
"Comparison",
|
|
22
|
+
"FXUnavailable",
|
|
23
|
+
"FundlyError",
|
|
24
|
+
"FundlyTimeout",
|
|
25
|
+
"History",
|
|
26
|
+
"Instrument",
|
|
27
|
+
"InvalidId",
|
|
28
|
+
"NotFound",
|
|
29
|
+
"PricePoint",
|
|
30
|
+
"Quote",
|
|
31
|
+
"UnknownSource",
|
|
32
|
+
"__version__",
|
|
33
|
+
"compare",
|
|
34
|
+
"history",
|
|
35
|
+
"instrument",
|
|
36
|
+
"quote",
|
|
37
|
+
"search",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Silent unless the application configures logging; source fallbacks are logged at WARNING on "fundly".
|
|
41
|
+
logging.getLogger("fundly").addHandler(logging.NullHandler())
|
|
42
|
+
|
|
43
|
+
_default: Client | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _client() -> Client:
|
|
47
|
+
global _default
|
|
48
|
+
if _default is None:
|
|
49
|
+
_default = Client()
|
|
50
|
+
return _default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def search(q: str = "", **kwargs) -> list[Instrument]:
|
|
54
|
+
return _client().search(q, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def instrument(id: str) -> Instrument:
|
|
58
|
+
return _client().instrument(id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def quote(id: str, source: str | None = None, currency: str | None = None) -> Quote:
|
|
62
|
+
return _client().quote(id, source, currency)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def history(id: str, start=None, end=None, source: str | None = None, currency: str | None = None) -> History:
|
|
66
|
+
return _client().history(id, start, end, source, currency)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compare(id: str) -> Comparison:
|
|
70
|
+
return _client().compare(id)
|
fundly/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
fundly/cache.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import unicodedata
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .currency import major
|
|
11
|
+
from .ids import split
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_cache_dir() -> Path:
|
|
15
|
+
"""$FUNDLY_CACHE_DIR, else the platform's cache folder."""
|
|
16
|
+
if os.environ.get("FUNDLY_CACHE_DIR"):
|
|
17
|
+
return Path(os.environ["FUNDLY_CACHE_DIR"]).expanduser()
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "fundly" / "Cache"
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
return Path.home() / "Library" / "Caches" / "fundly"
|
|
22
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "fundly"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Letters that NFKD does not decompose into a base letter + accent (applied after lowercasing).
|
|
26
|
+
_FOLD = str.maketrans({"đ": "d", "ł": "l", "ø": "o", "æ": "ae", "œ": "oe", "ß": "ss", "ı": "i", "þ": "th", "ð": "d"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize(text: str | None) -> str:
|
|
30
|
+
"""Lowercase, accents stripped, punctuation to spaces: 'Multigestión' and 'multigestion' match."""
|
|
31
|
+
if not text:
|
|
32
|
+
return ""
|
|
33
|
+
t = unicodedata.normalize("NFKD", text.lower().translate(_FOLD))
|
|
34
|
+
t = "".join(c for c in t if not unicodedata.combining(c)).lower().translate(_FOLD)
|
|
35
|
+
return " ".join("".join(c if c.isalnum() else " " for c in t).split())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
INSTRUMENT_COLS = ("id", "id_type", "name", "type", "currency", "domicile", "provider", "category",
|
|
39
|
+
"ter", "size_eur", "ticker", "morningstar_id", "finect_id")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
BUSY_TIMEOUT = 30 # seconds to wait for another process holding the write lock
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Cache:
|
|
46
|
+
"""Local SQLite store: instruments seen in searches plus cached quotes and price series."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, directory: Path | None):
|
|
49
|
+
if directory is None:
|
|
50
|
+
target = ":memory:"
|
|
51
|
+
else:
|
|
52
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
target = str(directory / "fundly.db")
|
|
54
|
+
# Autocommit mode: transactions are opened explicitly in tx(). Other processes may share the file,
|
|
55
|
+
# so a busy database is waited for instead of failing.
|
|
56
|
+
self._conn = sqlite3.connect(target, check_same_thread=False, isolation_level=None, timeout=BUSY_TIMEOUT)
|
|
57
|
+
self._conn.row_factory = sqlite3.Row
|
|
58
|
+
self._lock = threading.RLock()
|
|
59
|
+
if directory is not None:
|
|
60
|
+
self._enable_wal()
|
|
61
|
+
with self.tx() as c:
|
|
62
|
+
if c.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
|
|
63
|
+
# It's only a cache: on a schema change, start over instead of migrating.
|
|
64
|
+
for (table,) in c.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall():
|
|
65
|
+
c.execute(f'DROP TABLE IF EXISTS "{table}"')
|
|
66
|
+
c.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
67
|
+
for statement in SCHEMA.split(";"):
|
|
68
|
+
if statement.strip():
|
|
69
|
+
c.execute(statement)
|
|
70
|
+
|
|
71
|
+
def _enable_wal(self):
|
|
72
|
+
"""Switching to WAL needs an exclusive lock and SQLite doesn't wait for it, so when several processes
|
|
73
|
+
open a new cache at once, the losers get "database is locked" at once. Retry up to BUSY_TIMEOUT."""
|
|
74
|
+
deadline, delay = time.monotonic() + BUSY_TIMEOUT, 0.005
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
78
|
+
return
|
|
79
|
+
except sqlite3.OperationalError as e:
|
|
80
|
+
if "locked" not in str(e) or time.monotonic() > deadline:
|
|
81
|
+
raise
|
|
82
|
+
time.sleep(delay)
|
|
83
|
+
delay = min(delay * 2, 0.25)
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def tx(self):
|
|
87
|
+
# BEGIN IMMEDIATE takes the write lock up front. A deferred transaction that reads first and writes
|
|
88
|
+
# later fails at once ("database is locked") if another process wrote in between, instead of waiting.
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
91
|
+
try:
|
|
92
|
+
yield self._conn
|
|
93
|
+
self._conn.execute("COMMIT")
|
|
94
|
+
except BaseException:
|
|
95
|
+
self._conn.execute("ROLLBACK")
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
def all(self, sql: str, params=()) -> list[sqlite3.Row]:
|
|
99
|
+
with self._lock:
|
|
100
|
+
return self._conn.execute(sql, params).fetchall()
|
|
101
|
+
|
|
102
|
+
def one(self, sql: str, params=()) -> sqlite3.Row | None:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return self._conn.execute(sql, params).fetchone()
|
|
105
|
+
|
|
106
|
+
def upsert_instruments(self, items: list[dict]):
|
|
107
|
+
"""Insert new instruments; for known ones only fill fields that are still empty."""
|
|
108
|
+
cols = (*INSTRUMENT_COLS, "search_text")
|
|
109
|
+
rows, aliases = [], []
|
|
110
|
+
for it in items:
|
|
111
|
+
it = {k: " ".join(v.split()) if isinstance(v, str) else v for k, v in it.items()}
|
|
112
|
+
it["id_type"] = split(it["id"])[0]
|
|
113
|
+
it["currency"] = major(it.get("currency"))
|
|
114
|
+
names = " ".join(filter(None, [it.get("name"), it.get("alt_name"), it["id"]]))
|
|
115
|
+
rows.append((*tuple(it.get(c) for c in INSTRUMENT_COLS), normalize(names)))
|
|
116
|
+
# Other codes for the same instrument, so a later lookup by any of them lands on this id.
|
|
117
|
+
for alias in [*it.get("aliases", []),
|
|
118
|
+
*([f"ticker:{it['ticker'].upper()}"] if it.get("ticker") else []),
|
|
119
|
+
*([f"ms:{it['morningstar_id'].upper()}"] if it.get("morningstar_id") else [])]:
|
|
120
|
+
if alias != it["id"]:
|
|
121
|
+
aliases.append((alias, it["id"]))
|
|
122
|
+
with self.tx() as c:
|
|
123
|
+
c.executemany(
|
|
124
|
+
f"INSERT INTO instruments ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
|
|
125
|
+
"ON CONFLICT(id) DO UPDATE SET "
|
|
126
|
+
+ ", ".join(f"{k} = COALESCE(instruments.{k}, excluded.{k})" for k in INSTRUMENT_COLS[1:])
|
|
127
|
+
+ ", search_text = CASE WHEN instr(instruments.search_text, excluded.search_text) > 0 "
|
|
128
|
+
"THEN instruments.search_text ELSE instruments.search_text || ' ' || excluded.search_text END"
|
|
129
|
+
+ ", updated_at = datetime('now')",
|
|
130
|
+
rows,
|
|
131
|
+
)
|
|
132
|
+
c.executemany("INSERT OR IGNORE INTO aliases (alias, id) VALUES (?, ?)", aliases)
|
|
133
|
+
|
|
134
|
+
def close(self):
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._conn.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
SCHEMA_VERSION = 5
|
|
140
|
+
SCHEMA = """
|
|
141
|
+
CREATE TABLE IF NOT EXISTS instruments (
|
|
142
|
+
id TEXT PRIMARY KEY, -- canonical id: ISIN, DGS code, or scheme:code (see fundly.ids)
|
|
143
|
+
id_type TEXT NOT NULL,
|
|
144
|
+
name TEXT NOT NULL,
|
|
145
|
+
type TEXT NOT NULL, -- etf | fund | plan
|
|
146
|
+
currency TEXT,
|
|
147
|
+
domicile TEXT,
|
|
148
|
+
provider TEXT,
|
|
149
|
+
category TEXT,
|
|
150
|
+
ter REAL,
|
|
151
|
+
size_eur INTEGER,
|
|
152
|
+
ticker TEXT,
|
|
153
|
+
morningstar_id TEXT,
|
|
154
|
+
finect_id TEXT,
|
|
155
|
+
search_text TEXT NOT NULL DEFAULT '',
|
|
156
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_instruments_ticker ON instruments(ticker);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_instruments_ms ON instruments(morningstar_id);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS aliases (
|
|
163
|
+
alias TEXT PRIMARY KEY, -- another canonical id for the same instrument (ticker:VTSAX, in-amfi:118989)
|
|
164
|
+
id TEXT NOT NULL
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE TABLE IF NOT EXISTS search_log (
|
|
168
|
+
q TEXT PRIMARY KEY,
|
|
169
|
+
ids TEXT NOT NULL, -- JSON list, relevance order from the live sources
|
|
170
|
+
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
CREATE TABLE IF NOT EXISTS quote_cache (
|
|
174
|
+
id TEXT NOT NULL,
|
|
175
|
+
source TEXT NOT NULL,
|
|
176
|
+
price REAL NOT NULL,
|
|
177
|
+
price_date TEXT NOT NULL,
|
|
178
|
+
currency TEXT NOT NULL,
|
|
179
|
+
price_type TEXT NOT NULL,
|
|
180
|
+
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
181
|
+
PRIMARY KEY (id, source)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS price_cache (
|
|
185
|
+
id TEXT NOT NULL,
|
|
186
|
+
source TEXT NOT NULL,
|
|
187
|
+
date TEXT NOT NULL,
|
|
188
|
+
price REAL NOT NULL,
|
|
189
|
+
PRIMARY KEY (id, source, date)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE TABLE IF NOT EXISTS history_fetches (
|
|
193
|
+
id TEXT NOT NULL,
|
|
194
|
+
source TEXT NOT NULL,
|
|
195
|
+
start_date TEXT NOT NULL,
|
|
196
|
+
end_date TEXT NOT NULL,
|
|
197
|
+
currency TEXT NOT NULL,
|
|
198
|
+
price_type TEXT NOT NULL,
|
|
199
|
+
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_fetches ON history_fetches(id, source, fetched_at DESC);
|
|
203
|
+
|
|
204
|
+
CREATE TABLE IF NOT EXISTS misses (
|
|
205
|
+
id TEXT NOT NULL,
|
|
206
|
+
source TEXT NOT NULL,
|
|
207
|
+
kind TEXT NOT NULL, -- "quote", or "history:<start>:<end>"
|
|
208
|
+
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
209
|
+
PRIMARY KEY (id, source, kind)
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
CREATE TABLE IF NOT EXISTS fx_rates (
|
|
213
|
+
currency TEXT NOT NULL,
|
|
214
|
+
source TEXT NOT NULL,
|
|
215
|
+
date TEXT NOT NULL,
|
|
216
|
+
per_eur REAL NOT NULL, -- units of `currency` per euro
|
|
217
|
+
PRIMARY KEY (currency, source, date)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
CREATE TABLE IF NOT EXISTS fx_fetches (
|
|
221
|
+
currency TEXT NOT NULL,
|
|
222
|
+
source TEXT NOT NULL,
|
|
223
|
+
start_date TEXT NOT NULL,
|
|
224
|
+
end_date TEXT NOT NULL,
|
|
225
|
+
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
226
|
+
);
|
|
227
|
+
"""
|