mantis-imf 0.1.8__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ # IDE files
2
+ .vscode/
3
+
4
+ # Python-generated files
5
+ __pycache__/
6
+ *.py[oc]
7
+ build/
8
+ dist/
9
+ wheels/
10
+ *.egg-info
11
+ .pytest_cache/
12
+ .python-version
13
+
14
+ # Virtual environments
15
+ .venv
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: mantis-imf
3
+ Version: 0.1.8
4
+ Summary: Instrument Management Facility for MANTIS
5
+ Author-email: Louis Zhuang <yzhuang@outlook.com>
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: adbc-driver-postgresql>=1.12.0
8
+ Requires-Dist: asof-store[sql-postgres,sql-sqlite]
9
+ Requires-Dist: polars[adbc,pyarrow]>=2.0.0
10
+ Requires-Dist: pydantic>=2.0
11
+ Requires-Dist: zapros>=0.20.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # MANTIS Instrument Management Facility
15
+
16
+ `imf` searches and updates IB instrument records in an `asof-store` database.
17
+ Configure the SQL backend once in the repository's `service-registrar.toml`:
18
+
19
+ ```toml
20
+ ["asof-store.sql"]
21
+ sql_uri = "sqlite:///ib-instruments.db"
22
+ ```
23
+
24
+ IMF supplies each store's table and value types; `sql_uri` comes from this
25
+ section by default. Pass `--sql-uri URI` to any command to override it for that
26
+ invocation. For PostgreSQL, set `sql_uri` to the database connection URI. The
27
+ `imf index` command requires PostgreSQL and adds trigram indexes to the
28
+ instrument and template tables.
29
+
30
+ ## Searching
31
+
32
+ ```powershell
33
+ imf search --symbol AAPL
34
+ imf search --country US --exchange NASDAQ --product-type STK
35
+ imf search --local-symbol "ESZ6"
36
+ imf search --field currency=USD --field "contract.exchange=SMART"
37
+ imf search --as-of 2026-10-29T12:00:00+00:00 --symbol AAPL
38
+ ```
39
+
40
+ Search filters use case-insensitive substring matching by default; pass
41
+ `--exact` for exact matches. Repeat `--field NAME=VALUE` to filter additional
42
+ JSON fields. Dotted names address nested fields. Results are one JSON object
43
+ per line. `--table-name` and `--missing-primary-key-table-name` override the
44
+ corresponding table names.
45
+
46
+ Use `imf fuzzy` to search standard instrument fields with multiple values. Each
47
+ value must match at least one field. With no values, it opens an interactive
48
+ REPL.
49
+
50
+ ```powershell
51
+ imf fuzzy AAPL NASDAQ
52
+ imf index
53
+ ```
54
+
55
+ ## Scraping
56
+
57
+ ```powershell
58
+ imf scrape-instruments
59
+ imf scrape-reconcile
60
+ imf scrape-funds
61
+ imf scrape-exchanges
62
+ ```
63
+
64
+ The instrument commands accept repeated `--product-type` and
65
+ `--product-country` filters. Use `--dry-run` to print the remote product summary
66
+ without opening a store. Run any command with `--help` to see its full options.
@@ -0,0 +1,53 @@
1
+ # MANTIS Instrument Management Facility
2
+
3
+ `imf` searches and updates IB instrument records in an `asof-store` database.
4
+ Configure the SQL backend once in the repository's `service-registrar.toml`:
5
+
6
+ ```toml
7
+ ["asof-store.sql"]
8
+ sql_uri = "sqlite:///ib-instruments.db"
9
+ ```
10
+
11
+ IMF supplies each store's table and value types; `sql_uri` comes from this
12
+ section by default. Pass `--sql-uri URI` to any command to override it for that
13
+ invocation. For PostgreSQL, set `sql_uri` to the database connection URI. The
14
+ `imf index` command requires PostgreSQL and adds trigram indexes to the
15
+ instrument and template tables.
16
+
17
+ ## Searching
18
+
19
+ ```powershell
20
+ imf search --symbol AAPL
21
+ imf search --country US --exchange NASDAQ --product-type STK
22
+ imf search --local-symbol "ESZ6"
23
+ imf search --field currency=USD --field "contract.exchange=SMART"
24
+ imf search --as-of 2026-10-29T12:00:00+00:00 --symbol AAPL
25
+ ```
26
+
27
+ Search filters use case-insensitive substring matching by default; pass
28
+ `--exact` for exact matches. Repeat `--field NAME=VALUE` to filter additional
29
+ JSON fields. Dotted names address nested fields. Results are one JSON object
30
+ per line. `--table-name` and `--missing-primary-key-table-name` override the
31
+ corresponding table names.
32
+
33
+ Use `imf fuzzy` to search standard instrument fields with multiple values. Each
34
+ value must match at least one field. With no values, it opens an interactive
35
+ REPL.
36
+
37
+ ```powershell
38
+ imf fuzzy AAPL NASDAQ
39
+ imf index
40
+ ```
41
+
42
+ ## Scraping
43
+
44
+ ```powershell
45
+ imf scrape-instruments
46
+ imf scrape-reconcile
47
+ imf scrape-funds
48
+ imf scrape-exchanges
49
+ ```
50
+
51
+ The instrument commands accept repeated `--product-type` and
52
+ `--product-country` filters. Use `--dry-run` to print the remote product summary
53
+ without opening a store. Run any command with `--help` to see its full options.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "mantis-imf"
3
+ dynamic = ["version"]
4
+ description = "Instrument Management Facility for MANTIS"
5
+ readme = "README.md"
6
+ authors = [{ name = "Louis Zhuang", email = "yzhuang@outlook.com" }]
7
+ requires-python = ">=3.14"
8
+ dependencies = [
9
+ "asof-store[sql-postgres,sql-sqlite]",
10
+ "pydantic>=2.0",
11
+ "zapros>=0.20.0",
12
+ "adbc-driver-postgresql>=1.12.0",
13
+ "polars[adbc,pyarrow]>=2.0.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ imf = "mantis_imf:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling", "uv-dynamic-versioning"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.version]
24
+ source = "uv-dynamic-versioning"
25
+
26
+ [tool.uv.sources]
27
+ #asof-store = { path = "../../../asof-store", editable = true }
@@ -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"]