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 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"]