Skip to content

Store and query results

DuckDBStore persists validated acquisition snapshots and cumulative research datasets. It opens one explicit DuckDB connection and is intended for one-process use.

Acquisition never writes to the store automatically. This lets you inspect, reject, or transform a provider result before deciding which normalized source observations to retain.

Create a new store

from pathlib import Path

from persistra.data import DuckDBStore

store = DuckDBStore.create(Path("research.duckdb"))
store.close()

create requires an absent path and creates parent directories when needed. It refuses to replace an existing file. Prefer a context manager so the connection closes on every path:

from persistra.data import DuckDBStore, synthetic

bars = synthetic.bars("DEMO", periods=30)

with DuckDBStore.create("research.duckdb") as store:
    snapshot_id = store.save(bars)
    print(snapshot_id)

Open an existing store

with DuckDBStore.open("research.duckdb") as store:
    restored = store.load_bars(bars.instrument.instrument_id)

Use a read-only connection when no writes are needed:

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    restored = store.load_bars(bars.instrument.instrument_id)

Opening validates the store schema version. Persistra does not migrate an unsupported database in place. Persistent catalogs require store schema version 1; create a new store instead of reusing an earlier-version file.

Persist an instrument catalog

save_catalog atomically merges an explicit Catalog into the store. Repeating the same save is idempotent. Conflicting instrument, listing, or provider identities are rejected before any row is written. load_catalog returns an isolated Catalog with complete referential validation.

from persistra.data import DuckDBStore
from persistra.model import Catalog, Instrument, InstrumentKind, Listing, ProviderSymbol

instrument = Instrument("company-a", InstrumentKind.EQUITY, "Company A")
listing = Listing("company-a-xnys", instrument.instrument_id, "CMPA", mic="XNYS")
mapping = ProviderSymbol(
    "example_provider",
    InstrumentKind.EQUITY,
    "CMPA",
    instrument.instrument_id,
    listing.listing_id,
)
catalog = Catalog()
catalog.add_instrument(instrument)
catalog.add_listing(listing)
catalog.map_provider_symbol(mapping)

with DuckDBStore.create("catalog.duckdb") as store:
    store.save_catalog(catalog)

Provider keys are exact and case-sensitive. Catalog persistence records only caller-approved identity relationships; it does not infer symbol equivalence or establish provider authority.

Verify complete store integrity

open checks the schema version needed for normal operations. Use verify_store for a complete read-only audit before archival, transfer, or incident diagnosis:

from persistra.data import verify_store

verification = verify_store("research.duckdb")
for finding in verification.findings:
    print(finding.code, finding.message)

if not verification.is_valid:
    raise RuntimeError("store integrity verification failed")

The verifier opens DuckDB in read-only mode. It checks required tables, columns, keys, references, and the supported schema version. It then recomputes every content hash and snapshot ID, decodes every family at every acquisition occurrence, checks retrieval chronology and snapshot inventory, and reconciles every payload-backed typed row table. It never repairs, migrates, or rewrites the database.

StoreVerification.to_dict() returns verification_version = 1, the absolute store path, validity, snapshot and occurrence counts when schema inspection succeeds, and ordered findings. All current store findings have error severity. Codes use these stable categories:

Codes Meaning
store.path.missing, store.open.invalid The requested path is absent or is not a readable DuckDB database.
store.schema.missing, store.schema.shape, store.schema.constraints A required schema object or contract is absent or changed.
store.schema.version, store.schema.version_unsupported The version inventory is malformed or unsupported.
store.inventory.order, store.reference.orphan Global occurrence order or a foreign reference is inconsistent.
store.snapshot.occurrence_missing, store.snapshot.hash, store.snapshot.identity Snapshot inventory, content identity, or occurrence ownership is inconsistent.
store.snapshot.payload, store.snapshot.family, store.snapshot.scope A stored payload cannot reproduce its declared family or scope.
store.occurrence.decode, store.occurrence.chronology Occurrence metadata cannot decode or disagrees with retrieval chronology.
store.rows.orphan, store.rows.family, store.rows.mismatch Typed rows are orphaned, stored in the wrong family table, or differ from the payload.
store.audit.failed An unexpected database failure prevented the audit from completing.

Use list_datasets, list_snapshots, and load_snapshot for generic read-only inspection. These methods expose immutable dataset and snapshot identities without requiring callers to query private DuckDB tables. The local inspector uses only this public API.

Export Arrow or Parquet files

Use export_store with an explicit selection and format. Exact selections retain one immutable snapshot. Cumulative selections combine the latest observed rows for one bars, series, vintage-series, or vintage-date dataset, optionally through a retrieval-time cutoff.

from persistra.data import (
    ColumnarFormat,
    CumulativeDatasetSelection,
    DuckDBStore,
    export_store,
)

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    exported = export_store(
        store,
        CumulativeDatasetSelection("bars", "synthetic:equity:DEMO"),
        "demo-bars.parquet",
        format=ColumnarFormat.PARQUET,
    )

print(exported.provenance_path)

The destination suffix must match .arrow or .parquet. Arrow IPC and Parquet files retain pandas metadata so nullable columns, calendar dates, and timezone-aware timestamps round-trip. Every export also writes a deterministic .provenance.json sidecar containing the selection, source snapshot identities, row counts, filenames, and SHA-256 file hashes.

Each output is written completely to a private same-directory staging file and published with an atomic filesystem operation. Existing data or sidecar paths are refused by default. Pass overwrite=True only when replacing every output is intentional. An option-chain snapshot has two normalized tables, so a destination such as options.parquet produces options.contracts.parquet, options.observations.parquet, and options.provenance.json.

Use ExactSnapshotSelection(snapshot_id) with an identity returned by save or list_snapshots to export one exact stored result. The sidecar records the snapshot content hash, observation bounds, acquisition order, and normalized result metadata.

Each save records an acquisition occurrence even when its normalized content matches an existing snapshot. Snapshots deduplicate immutable content; snapshot_count therefore counts distinct contents, while first_seen and last_seen cover all linked occurrences. Latest loads and cumulative row revisions follow retrieval time, with save order breaking equal-time ties. StoredSnapshot.saved_order records when distinct content first entered the store, while snapshot lists follow each content's most recent occurrence. load_snapshot returns the content with its earliest observed acquisition provenance.

Save supported result families

save validates and encodes one normalized result. It supports bars, quotes, top of book, exchange-rate quotes, commodity spot quotes, option chains, scalar series, vintage series, FRED vintage dates, market status, symbol search, and index catalogs.

from persistra.data import synthetic

results = [
    synthetic.bars("DEMO"),
    synthetic.quotes(("AAA", "BBB")),
    synthetic.top_of_book(("AAA", "BBB")),
    synthetic.option_chain("DEMO"),
    synthetic.series("CPI"),
    synthetic.vintage_series("GDP"),
    synthetic.vintage_dates("GDP"),
    synthetic.exchange_rate("EUR", "USD"),
    synthetic.commodity_spot("gold"),
    synthetic.search("DEMO"),
    synthetic.market_status(),
    synthetic.index_catalog(),
]

with DuckDBStore.create("all-results.duckdb") as store:
    snapshot_ids = [store.save(result) for result in results]

Saving an unsupported object raises TypeError.

Load by exact scope

Each result family has an explicit load method:

with DuckDBStore.open("all-results.duckdb") as store:
    loaded_bars = store.load_bars(results[0].instrument.instrument_id)
    loaded_quotes = store.load_quotes(("AAA", "BBB"))
    loaded_options = store.load_options(
        results[3].underlying_instrument_id,
        results[3].chain_date,
    )
    loaded_series = store.load_series(results[4].definition.series_id)
    loaded_vintages = store.load_vintage_series(results[5].definition.series_id)
    loaded_vintage_dates = store.load_vintage_dates(results[6].provider_series)

Quote and top-of-book loads use the exact symbol batch scope and order used at save time. Every load method returns one exact acquisition snapshot. It does not combine partial downloads. Load methods return None when the scope has no stored snapshot.

Query quote and top-of-book history

query_quote_history and query_top_of_book_history reconstruct observations across every retained batch scope. Filter by provider, provider symbol, observation time, and retrieval time. The methods return exact documented nullable and timezone-aware dtypes even when no rows match.

An identical normalized observation has one stable revision_id. Its first_retrieved_at, last_retrieved_at, and retrieval_count describe recurrence without duplicating source content. A changed source field creates a new revision. Overlapping batches observed at the same retrieval time count once for that revision.

Both frames preserve their normalized quote or top-of-book columns and dtypes except for the acquisition-specific retrieved_at column. They add revision_id (string), first_retrieved_at and last_retrieved_at (datetime64[ns, UTC]), and retrieval_count (Int64).

with DuckDBStore.open("all-results.duckdb", read_only=True) as store:
    quote_history = store.query_quote_history(provider="synthetic", symbol="AAA")
    book_history = store.query_top_of_book_history(symbol="AAA")

Query option-chain snapshots

query_option_snapshots returns retrieval-ordered StoredOptionSnapshot values. Filters for chain date, expiration, strike, and option type select contracts and their matching observations; they never reinterpret the provider-native chain date. A retained chain with no matching contract is represented explicitly by schema-correct empty contract and observation frames.

with DuckDBStore.open("all-results.duckdb", read_only=True) as store:
    history = store.query_option_snapshots(
        results[3].underlying_instrument_id,
        option_type="call",
    )

Compare exact snapshots

diff_snapshots compares two snapshots from the same family. SnapshotDiff reports normalized rows added or removed, individual changed field values, acquisition metadata changes, and schema diagnostics. source_changed and provenance_changed keep provider content distinct from how and when Persistra acquired it. Row identities follow each normalized family's contract.

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    difference = store.diff_snapshots(before_snapshot_id, after_snapshot_id)

Query bars inside DuckDB

query_bars combines every retained partial download for one instrument. For each bar identity, it returns the row from the latest acquisition that contained that identity. Interval and temporal filters are inclusive:

from datetime import date

with DuckDBStore.open("research.duckdb") as store:
    frame = store.query_bars(
        bars.instrument.instrument_id,
        interval="daily",
        start=date(2025, 1, 10),
        end=date(2025, 1, 20),
    )

print(frame[["date", "close"]])

The method returns an empty frame with the exact bar dtypes when nothing matches. Use date bounds for daily rows and timezone-aware datetime bounds for intraday rows. Mixed bound types and naive datetimes are rejected.

This lets separate intervals or date windows form one research dataset without making an acquisition snapshot pretend to be complete. A later overlapping row supersedes the earlier row; nonoverlapping rows remain available. Use load_bars when the exact latest provider result is required instead.

Query scalar series

Period-label filters are inclusive and applied inside DuckDB. Separate retained period ranges accumulate under the series identity, and a later observation of the same normalized row supersedes its earlier value:

series = synthetic.series("CPI", periods=24)

with DuckDBStore.create("series.duckdb") as store:
    store.save(series)
    frame = store.query_series(
        series.definition.series_id,
        start_label="2024-06",
        end_label="2025-01",
    )

print(frame[["period_label", "value"]])

Use source-native period labels. Persistra does not reinterpret or coerce their frequency.

Query a provider-native revision history

query_vintage_series filters period labels and can select the version available on an explicit date:

from datetime import date

history = synthetic.vintage_series("GDP", periods=24)

with DuckDBStore.create("vintages.duckdb") as store:
    store.save(history)
    point_in_time = store.query_vintage_series(
        history.definition.series_id,
        start_label="2023-01-01",
        end_label="2023-12-01",
        available_on=date(2024, 1, 15),
    )

Availability bounds are inclusive. A missing available_through remains applicable after available_from. Separate retained observation ranges and newly observed provider versions accumulate. If Persistra observes the same provider-version identity again, the latest retained row wins. load_vintage_series still returns one exact acquisition snapshot.

Page cumulative queries

Use the page variants when a cumulative result may be too large to materialize in application memory or send to a browser. query_bars_page, query_series_page, and query_vintage_series_page apply their family filters and sorting inside DuckDB:

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    page = store.query_bars_page(
        bars.instrument.instrument_id,
        interval="daily",
        limit=100,
        offset=0,
        sort_by="close",
        descending=True,
    )

print(page.total_count)
print(page.frame)

StoredPage.frame contains at most limit rows. total_count is the exact count after filters, while has_previous and has_next support explicit navigation. Limits must be between 1 and 1,000, offsets must be nonnegative, and sort columns are restricted to the normalized schema. Every order adds the dataset identity columns as deterministic tie-breakers, so adjacent pages do not overlap or drift while the store remains unchanged. An offset beyond the final row returns an empty typed frame and preserves the exact total.

The non-page query methods remain useful when callers intentionally need the complete cumulative frame. Both forms use the same latest-observed row-revision and point-in-time cutoff rules.

Reconstruct what Persistra had observed

Changed values create a new retrieval-time revision. Pass a timezone-aware retrieved_before value to reconstruct the retained state at or before that time:

from datetime import UTC, datetime

cutoff = datetime(2025, 2, 1, tzinfo=UTC)

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    historical_snapshot = store.load_bars(
        bars.instrument.instrument_id,
        retrieved_before=cutoff,
    )
    historical_dataset = store.query_bars(
        bars.instrument.instrument_id,
        retrieved_before=cutoff,
    )

The load selects one exact snapshot. The query chooses the latest eligible revision of every retained row and keeps nonoverlapping rows from earlier partial acquisitions. Both are records of what Persistra had observed, not claims that the provider offered point-in-time or unrevised historical data. Naive cutoffs are rejected.

Repeated identical content reuses its content-derived snapshot ID and updates last_seen. Changed content creates another snapshot with a new first_seen time.

Inspect a stored payload

latest_payload returns a decoded copy for research diagnostics:

with DuckDBStore.open("research.duckdb", read_only=True) as store:
    payload = store.latest_payload(
        "bars",
        bars.instrument.instrument_id,
    )

if payload is not None:
    print(payload.keys())

Prefer typed load and query methods in application code. The payload method exposes the serialized representation and is intentionally lower level.

Handle store lifecycle safely

  • Use one context manager per unit of work.
  • Do not share a store connection across processes.
  • Open read-only for reporting jobs that do not save results.
  • Keep raw provider caches separate from normalized database files.
  • Back up the database as ordinary research data; the library does not manage backups.
  • Treat unsupported schema errors as a signal to create a new database for the current version.