chainmill
Zipped option chains in, queryable store out.
Parallel ingest of vendor option-chain archives into SQLite, with resumable builds and a coverage line you can quote. Map: one worker parses one archive. Reduce: the parent folds every frame into one store, on one connection.
pip install chainmill
Thirty seconds
from chainmill import build, ChainQuery
if __name__ == "__main__": # required on macOS and Windows
report = build("raw_archives/", "chains.db", max_workers=8)
print(report.summary())
60/60 archives ingested (0 skipped, 0 failed) - 120,000 rows, 60 sessions
2024-01-02 to 2024-03-01
Then query it:
with ChainQuery("chains.db") as q:
chain = q.chain("2024-01-02", symbol="SPY", option_type="P")
wide = q.strikes("2024-01-02", 400, 460)
busy = q.liquid("2024-01-02", min_volume=500)
panel = q.daily_summary()
Re-running skips archives already ingested, so an interrupted multi-year load resumes instead of duplicating rows.
When you outgrow SQLite, there is a door out:
with ChainQuery("chains.db") as q:
q.to_parquet("chains.parquet") # one file, streamed
q.to_parquet("chains/", partition_by_session=True) # Hive layout, date=YYYY-MM-DD/
Streamed in batches rather than loaded whole, so a multi-year store does not have to fit in
memory. Needs pip install chainmill[parquet].
What it handles
- Vendor drift. Headers matched case-insensitively with aliases (
iv→implied_volatility,volume→trade_volume,expiry→expiration). A file genuinely missing required columns is reported by name, never silently coerced. - Bad archives. A corrupt or dateless file is recorded as a failure in the report; it does not abort a 1,400-archive build.
- Multi-member archives. All CSV members concatenated.
- Session dates parsed from
chain_2024-01-02.zip,SPY20240102.ziporx_2024_01_02.zip, or passed explicitly. - Coverage reporting. Rows in, archives ingested / skipped / failed, and the session range — the line a result should be quoted with.
Throughput on a four-core laptop: 60 archives / 120,000 rows in 2.0s including index build. Real archives are larger and compression-bound.
Where this fits
| If you want | Use |
|---|---|
| A market-data platform with feeds | OpenBB |
| A general dataframe / warehouse layer | DuckDB, Polars, Parquet |
| To turn a folder of vendor zips into something queryable, correctly | chainmill |
SQLite and pandas, nothing else — pyarrow only if you export. It is an ingest and lookup layer,
not an analytics engine and not a distributed system. Beyond one machine, to_parquet() and point
a real warehouse at it.
No market data is included — option-chain licences do not permit redistribution.
Why this exists
This replaces five hand-rolled extractors that accumulated in a private research program over two years. Before rewriting anything, each was tested against a synthetic archive:
| extractor | rows parsed | rows persisted |
|---|---|---|
optimized_extractor |
0 | 0 |
extract_and_index |
50 | none — map only |
robust_extractor |
50 | 50 |
space_efficient_processor |
50 | 0 |
Four defects, each producing a plausible-looking run:
The reader outlived its archive. pd.read_csv(handle, chunksize=...) returns a lazy reader.
It was created inside with ZipFile(...) / with archive.open(...) and iterated after both
closed — so every archive raised I/O operation on closed file, was swallowed by a bare except,
and reported zero rows while printing a line that looked like progress. Here the member is read to
completion inside the block, with a regression test that touches the frame after the archive has
closed.
Workers were bound methods. Submitting self.extract_file to a ProcessPoolExecutor pickles
the entire processor to every worker. Each mutated its own copy of the in-memory indexes; those
mutations died with the process, and the parent then wrote the empty originals to disk. The map
step here is a module-level function of a path, with nothing shared.
The reduce step was distributed. Workers each opened their own SQLite connection and inserted
row-by-row in a Python loop — lock contention plus per-row overhead, and the reason one extractor
persisted nothing. One writer, executemany, batched transactions, WAL.
Indexes were built before the load, so every insert paid for them. create_indexes() now runs
once, after.
Tests
pip install -e ".[test]"
pytest -q
38 tests, no fixtures on disk — archives are synthesised in tmp_path. Includes an equivalence
test asserting the parallel and in-process builds produce identical stores, which is what keeps
the map step honest about being a pure function.
Licence
MIT. See CHANGELOG.md and CONTRIBUTING.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file chainmill-0.1.0.tar.gz.
File metadata
- Download URL: chainmill-0.1.0.tar.gz
- Upload date:
- Size: 15.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
45639d1d00bd14052dce9905d4de53d37e68a57358246eac065545f24a408cb4
|
|
| MD5 |
a263af9ada6c25b133e5987f64588562
|
|
| BLAKE2b-256 |
32b8a3fa4527ab976f35460edd37eb65f733f35381836a8820638202c35c7d42
|
Provenance
The following attestation bundles were made for chainmill-0.1.0.tar.gz:
Publisher:
publish.yml on charlieyanhx/chainmill
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
chainmill-0.1.0.tar.gz -
Subject digest:
45639d1d00bd14052dce9905d4de53d37e68a57358246eac065545f24a408cb4 - Sigstore transparency entry: 2704487206
- Sigstore integration time:
-
Permalink:
charlieyanhx/chainmill@b4ec74bd6df47cbf2c08e9585f4003c280744052 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/charlieyanhx
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b4ec74bd6df47cbf2c08e9585f4003c280744052 -
Trigger Event:
push
-
Statement type:
File details
Details for the file chainmill-0.1.0-py3-none-any.whl.
File metadata
- Download URL: chainmill-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6af9e47ae089bd72a90673bba67f04ba4d88ee2c0c73b65e73f3c8b9f9f96528
|
|
| MD5 |
f67cae10d9410b56810eb917cf16358f
|
|
| BLAKE2b-256 |
74e3a94180e264a664c504f02f67789f983a7eefc049421ef131547d346f7122
|
Provenance
The following attestation bundles were made for chainmill-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on charlieyanhx/chainmill
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
chainmill-0.1.0-py3-none-any.whl -
Subject digest:
6af9e47ae089bd72a90673bba67f04ba4d88ee2c0c73b65e73f3c8b9f9f96528 - Sigstore transparency entry: 2704487228
- Sigstore integration time:
-
Permalink:
charlieyanhx/chainmill@b4ec74bd6df47cbf2c08e9585f4003c280744052 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/charlieyanhx
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b4ec74bd6df47cbf2c08e9585f4003c280744052 -
Trigger Event:
push
-
Statement type: