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.
Metadata
Release files for chainmill 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| chainmill-0.1.0.tar.gz | 15.8 kB | With provenance | Details for chainmill-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| chainmill-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for chainmill-0.1.0-py3-none-any.whl |
Total release size: 29.7 kB
Release files / chainmill-0.1.0.tar.gz
| Download URL | chainmill-0.1.0.tar.gz |
|---|---|
| Size | 15.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
45639d1d00bd14052dce9905d4de53d37e68a57358246eac065545f24a408cb4
|
|
BLAKE2b-256 checksum How to use checksums |
32b8a3fa4527ab976f35460edd37eb65f733f35381836a8820638202c35c7d42
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logRelease files / chainmill-0.1.0-py3-none-any.whl
| Download URL | chainmill-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6af9e47ae089bd72a90673bba67f04ba4d88ee2c0c73b65e73f3c8b9f9f96528
|
|
BLAKE2b-256 checksum How to use checksums |
74e3a94180e264a664c504f02f67789f983a7eefc049421ef131547d346f7122
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logSecurity for chainmill 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for chainmill-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.