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.
- mantis_imf-0.1.8/.gitignore +15 -0
- mantis_imf-0.1.8/PKG-INFO +66 -0
- mantis_imf-0.1.8/README.md +53 -0
- mantis_imf-0.1.8/pyproject.toml +27 -0
- mantis_imf-0.1.8/src/mantis_imf/__init__.py +293 -0
- mantis_imf-0.1.8/src/mantis_imf/ib.py +756 -0
- mantis_imf-0.1.8/src/mantis_imf/ib_models.py +201 -0
- mantis_imf-0.1.8/src/mantis_imf/scraper.py +1366 -0
- mantis_imf-0.1.8/tests/integration/test_ib_live.py +227 -0
- mantis_imf-0.1.8/tests/test_cli.py +346 -0
- mantis_imf-0.1.8/tests/test_ib.py +956 -0
- mantis_imf-0.1.8/tests/test_scraper.py +1062 -0
|
@@ -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"]
|