mantis-imf 0.1.8__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.
- mantis_imf/__init__.py +293 -0
- mantis_imf/ib.py +756 -0
- mantis_imf/ib_models.py +201 -0
- mantis_imf/scraper.py +1366 -0
- mantis_imf-0.1.8.dist-info/METADATA +66 -0
- mantis_imf-0.1.8.dist-info/RECORD +8 -0
- mantis_imf-0.1.8.dist-info/WHEEL +4 -0
- mantis_imf-0.1.8.dist-info/entry_points.txt +2 -0
mantis_imf/__init__.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from asof_store import AsOfStore
|
|
12
|
+
|
|
13
|
+
from .ib_models import Instrument
|
|
14
|
+
|
|
15
|
+
_SEARCH_FIELDS = ("symbol", "local_symbol", "country", "exchange", "product_type")
|
|
16
|
+
_STORAGE_FIELD_ALIASES = {
|
|
17
|
+
"localSymbol": "local_symbol",
|
|
18
|
+
"exchange": "exchange_id",
|
|
19
|
+
"exchangeId": "exchange_id",
|
|
20
|
+
"productType": "product_type",
|
|
21
|
+
"type": "product_type",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="imf",
|
|
28
|
+
epilog=(
|
|
29
|
+
"Scraper commands: scrape-instruments, scrape-reconcile, "
|
|
30
|
+
"scrape-funds, scrape-exchanges"
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
search = commands.add_parser("search", help="search stored instruments")
|
|
35
|
+
_add_store_arguments(search)
|
|
36
|
+
for field in _SEARCH_FIELDS:
|
|
37
|
+
search.add_argument(f"--{field.replace('_', '-')}", help=f"match {field}")
|
|
38
|
+
search.add_argument(
|
|
39
|
+
"--field",
|
|
40
|
+
action="append",
|
|
41
|
+
default=[],
|
|
42
|
+
metavar="NAME=VALUE",
|
|
43
|
+
help="match any JSON field; dotted names address nested fields (repeatable)",
|
|
44
|
+
)
|
|
45
|
+
search.add_argument(
|
|
46
|
+
"--exact", action="store_true", help="match values exactly, ignoring case"
|
|
47
|
+
)
|
|
48
|
+
index = commands.add_parser(
|
|
49
|
+
"index", help="create PostgreSQL indexes for fast fuzzy searches"
|
|
50
|
+
)
|
|
51
|
+
_add_store_arguments(index)
|
|
52
|
+
fuzzy = commands.add_parser(
|
|
53
|
+
"fuzzy", help="search instruments for every supplied value across fields"
|
|
54
|
+
)
|
|
55
|
+
_add_store_arguments(fuzzy)
|
|
56
|
+
fuzzy.add_argument("values", nargs="*", metavar="VALUE")
|
|
57
|
+
fuzzy.add_argument(
|
|
58
|
+
"--exact", action="store_true", help="match values exactly, ignoring case"
|
|
59
|
+
)
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _add_store_arguments(parser: argparse.ArgumentParser) -> None:
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--sql-uri",
|
|
66
|
+
help="override the SQL URI from service-registrar.toml",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--table-name", default="ib_instruments")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--missing-primary-key-table-name", default="ib_instrument_templates"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--as-of",
|
|
74
|
+
type=datetime.fromisoformat,
|
|
75
|
+
help="only include instrument versions at or before this ISO timestamp",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _json_value(value: Any) -> Any:
|
|
80
|
+
if isinstance(value, Instrument):
|
|
81
|
+
return value.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
82
|
+
if isinstance(value, Mapping):
|
|
83
|
+
return value
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _filters(args: argparse.Namespace) -> list[tuple[str, str]]:
|
|
88
|
+
filters: list[tuple[str, str]] = []
|
|
89
|
+
for field in _SEARCH_FIELDS:
|
|
90
|
+
query = getattr(args, field)
|
|
91
|
+
if query is not None:
|
|
92
|
+
# asof-store persists Instrument models using Python field names.
|
|
93
|
+
storage_field = {
|
|
94
|
+
"local_symbol": "local_symbol",
|
|
95
|
+
"exchange": "exchange_id",
|
|
96
|
+
"product_type": "product_type",
|
|
97
|
+
}.get(field, field)
|
|
98
|
+
filters.append((storage_field, query))
|
|
99
|
+
for expression in args.field:
|
|
100
|
+
name, separator, query = expression.partition("=")
|
|
101
|
+
if not separator or not name or not query:
|
|
102
|
+
raise ValueError(f"invalid --field {expression!r}; expected NAME=VALUE")
|
|
103
|
+
parts = name.split(".")
|
|
104
|
+
parts[0] = _STORAGE_FIELD_ALIASES.get(parts[0], parts[0])
|
|
105
|
+
filters.append((".".join(parts), query))
|
|
106
|
+
return filters
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def search_instruments(argv: Sequence[str] | None = None) -> int:
|
|
110
|
+
args = _build_parser().parse_args(argv)
|
|
111
|
+
if args.command != "search":
|
|
112
|
+
raise ValueError(f"unsupported command: {args.command}")
|
|
113
|
+
return _search(args)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fuzzy_instruments(argv: Sequence[str] | None = None) -> int:
|
|
117
|
+
args = _build_parser().parse_args(argv)
|
|
118
|
+
if args.command != "fuzzy":
|
|
119
|
+
raise ValueError(f"unsupported command: {args.command}")
|
|
120
|
+
if not args.values:
|
|
121
|
+
return _fuzzy_repl(args)
|
|
122
|
+
return _search(args)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _search(args: argparse.Namespace) -> int:
|
|
126
|
+
_validate_table_names(args)
|
|
127
|
+
|
|
128
|
+
store = AsOfStore.from_sql(
|
|
129
|
+
sql_uri=args.sql_uri,
|
|
130
|
+
table_name=args.table_name,
|
|
131
|
+
timestamp_type=datetime,
|
|
132
|
+
key_type=int,
|
|
133
|
+
value_type=Instrument,
|
|
134
|
+
)
|
|
135
|
+
template_store = None
|
|
136
|
+
try:
|
|
137
|
+
template_store = AsOfStore.from_sql(
|
|
138
|
+
sql_uri=args.sql_uri,
|
|
139
|
+
table_name=args.missing_primary_key_table_name,
|
|
140
|
+
timestamp_type=datetime,
|
|
141
|
+
key_type=Instrument,
|
|
142
|
+
value_type=type(None),
|
|
143
|
+
)
|
|
144
|
+
_search_records(args, store, template_store)
|
|
145
|
+
finally:
|
|
146
|
+
store.close()
|
|
147
|
+
if template_store is not None:
|
|
148
|
+
template_store.close()
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _validate_table_names(args: argparse.Namespace) -> None:
|
|
153
|
+
if args.table_name == args.missing_primary_key_table_name:
|
|
154
|
+
raise ValueError("instrument and missing-primary-key table names must differ")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _create_trigram_indexes(store: Any, column: str, fields: tuple[str, ...]) -> None:
|
|
158
|
+
create_indexes = getattr(store, "create_trigram_indexes", None)
|
|
159
|
+
if create_indexes is None:
|
|
160
|
+
raise ValueError(f"{type(store).__name__} does not support trigram indexes")
|
|
161
|
+
create_indexes(fields, column=column)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _create_search_indexes(args: argparse.Namespace) -> int:
|
|
165
|
+
_validate_table_names(args)
|
|
166
|
+
fields = tuple(Instrument.model_fields)
|
|
167
|
+
store = AsOfStore.from_sql(
|
|
168
|
+
sql_uri=args.sql_uri,
|
|
169
|
+
table_name=args.table_name,
|
|
170
|
+
timestamp_type=datetime,
|
|
171
|
+
key_type=int,
|
|
172
|
+
value_type=Instrument,
|
|
173
|
+
)
|
|
174
|
+
template_store = None
|
|
175
|
+
try:
|
|
176
|
+
template_store = AsOfStore.from_sql(
|
|
177
|
+
sql_uri=args.sql_uri,
|
|
178
|
+
table_name=args.missing_primary_key_table_name,
|
|
179
|
+
timestamp_type=datetime,
|
|
180
|
+
key_type=Instrument,
|
|
181
|
+
value_type=type(None),
|
|
182
|
+
)
|
|
183
|
+
_create_trigram_indexes(store, "value", fields)
|
|
184
|
+
_create_trigram_indexes(template_store, "key", fields)
|
|
185
|
+
print(
|
|
186
|
+
"Ensured PostgreSQL trigram search indexes for "
|
|
187
|
+
f"{len(fields)} instrument fields."
|
|
188
|
+
)
|
|
189
|
+
finally:
|
|
190
|
+
store.close()
|
|
191
|
+
if template_store is not None:
|
|
192
|
+
template_store.close()
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _search_records(args: argparse.Namespace, store: Any, template_store: Any) -> None:
|
|
197
|
+
if args.command == "fuzzy":
|
|
198
|
+
fields = tuple(Instrument.model_fields)
|
|
199
|
+
terms = tuple(args.values)
|
|
200
|
+
records = store.as_of(args.as_of).get_all_matching_terms(
|
|
201
|
+
terms, fields, exact=args.exact
|
|
202
|
+
)
|
|
203
|
+
templates = template_store.as_of(args.as_of).get_keys_matching_terms(
|
|
204
|
+
terms, fields, exact=args.exact
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
filters = _filters(args)
|
|
208
|
+
if filters:
|
|
209
|
+
records = store.as_of(args.as_of).get_all_matching(
|
|
210
|
+
filters, exact=args.exact
|
|
211
|
+
)
|
|
212
|
+
templates = template_store.as_of(args.as_of).get_keys_matching(
|
|
213
|
+
filters, exact=args.exact
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
records = store.get_all(args.as_of)
|
|
217
|
+
templates = template_store.get_keys_matching([], as_of=args.as_of)
|
|
218
|
+
for source_records in (records, templates):
|
|
219
|
+
for value in source_records:
|
|
220
|
+
record = _json_value(value)
|
|
221
|
+
if record is not None:
|
|
222
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _fuzzy_repl(args: argparse.Namespace) -> int:
|
|
226
|
+
if args.table_name == args.missing_primary_key_table_name:
|
|
227
|
+
raise ValueError("instrument and missing-primary-key table names must differ")
|
|
228
|
+
|
|
229
|
+
store = AsOfStore.from_sql(
|
|
230
|
+
sql_uri=args.sql_uri,
|
|
231
|
+
table_name=args.table_name,
|
|
232
|
+
timestamp_type=datetime,
|
|
233
|
+
key_type=int,
|
|
234
|
+
value_type=Instrument,
|
|
235
|
+
)
|
|
236
|
+
template_store = None
|
|
237
|
+
try:
|
|
238
|
+
template_store = AsOfStore.from_sql(
|
|
239
|
+
sql_uri=args.sql_uri,
|
|
240
|
+
table_name=args.missing_primary_key_table_name,
|
|
241
|
+
timestamp_type=datetime,
|
|
242
|
+
key_type=Instrument,
|
|
243
|
+
value_type=type(None),
|
|
244
|
+
)
|
|
245
|
+
print("Enter values to search; use exit or quit to leave.")
|
|
246
|
+
while True:
|
|
247
|
+
try:
|
|
248
|
+
line = input("imf fuzzy> ")
|
|
249
|
+
except EOFError:
|
|
250
|
+
print()
|
|
251
|
+
break
|
|
252
|
+
except KeyboardInterrupt:
|
|
253
|
+
print()
|
|
254
|
+
break
|
|
255
|
+
try:
|
|
256
|
+
terms = shlex.split(line)
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
print(f"imf fuzzy: {exc}", file=sys.stderr)
|
|
259
|
+
continue
|
|
260
|
+
if not terms:
|
|
261
|
+
continue
|
|
262
|
+
if len(terms) == 1 and terms[0].casefold() in {"exit", "quit"}:
|
|
263
|
+
break
|
|
264
|
+
args.values = terms
|
|
265
|
+
_search_records(args, store, template_store)
|
|
266
|
+
finally:
|
|
267
|
+
store.close()
|
|
268
|
+
if template_store is not None:
|
|
269
|
+
template_store.close()
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main() -> None:
|
|
274
|
+
try:
|
|
275
|
+
argv = sys.argv[1:]
|
|
276
|
+
if argv and argv[0].startswith("scrape-"):
|
|
277
|
+
from .scraper import main as scraper_main
|
|
278
|
+
|
|
279
|
+
raise SystemExit(scraper_main(argv))
|
|
280
|
+
args = _build_parser().parse_args(argv)
|
|
281
|
+
if args.command == "index":
|
|
282
|
+
result = _create_search_indexes(args)
|
|
283
|
+
elif args.command == "fuzzy" and not args.values:
|
|
284
|
+
result = _fuzzy_repl(args)
|
|
285
|
+
else:
|
|
286
|
+
result = _search(args)
|
|
287
|
+
raise SystemExit(result)
|
|
288
|
+
except (ValueError, ImportError) as exc:
|
|
289
|
+
print(f"imf: error: {exc}", file=sys.stderr)
|
|
290
|
+
raise SystemExit(2) from exc
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
__all__ = ["fuzzy_instruments", "main", "search_instruments"]
|