ml4t-data stores time-series data as Parquet files with two backend strategies: Hive (partitioned by time) and Flat (single file per key). Both backends share atomic writes, file locking, metadata tracking, and Polars lazy evaluation.
| Hive | Flat | |
|---|---|---|
| Best for | Large datasets, time-range queries | Small datasets, simple access |
| Layout | Directory tree with year=.../month=.../data.parquet |
Single .parquet file per key |
| Date filtering | Prunes partitions before reading | Reads the single file and filters rows |
| Write cost | Higher (one file per partition) | Lower (single file) |
| Default | Yes | No |
from ml4t.data.storage import create_storage
# Hive storage (default)
storage = create_storage("./data", strategy="hive")
# Flat storage
storage = create_storage("./data", strategy="flat")Hive storage partitions data by time. Choose granularity based on your data frequency to keep partition sizes in the 200-5,000 row range:
| Granularity | Partition columns | Best for | Rows/partition (stocks) |
|---|---|---|---|
year |
year/ |
Daily data | ~252 |
month |
year=/month=/ |
Hourly data | ~720 |
day |
year=/month=/day=/ |
Minute data | ~1,440 |
hour |
year=/month=/day=/hour=/ |
Second/tick data | ~3,600 |
from ml4t.data.storage import HiveStorage, StorageConfig
# Daily equity data -- partition by year
config = StorageConfig(
base_path="./data",
partition_granularity="year",
)
storage = HiveStorage(config)
# Minute crypto data -- partition by day
config = StorageConfig(
base_path="./data",
partition_granularity="day",
)
storage = HiveStorage(config)On-disk layout for month-level partitioning. Storage keys can use slashes, such as
equities/daily/AAPL. Each key is encoded into one filesystem-safe directory and
published through a CURRENT commit pointer:
data/
k1_<encoded-key>/
CURRENT
commits/
generations/
<generation-id>/
year=2024/
month=1/
data.parquet
month=2/
data.parquet
.metadata/
k1_<encoded-key>.lock
Pre-0.1 development releases used ambiguous flattened filesystem names. The current storage backends report those entries but do not guess their logical keys. Inventory the old names, supply an explicit mapping, and run the verified migration:
from ml4t.data.storage import find_legacy_storage_entries, migrate_legacy_storage
entries = find_legacy_storage_entries("./data", "hive")
print([entry.physical_key for entry in entries])
migrate_legacy_storage(
"./data",
"hive",
{"equities_daily_BRK_B": "equities/daily/BRK_B"},
)The mapping must cover the complete inventory. Migration writes and reads back each
new dataset before moving its legacy files to .legacy-v0-backup. Pass the same
StorageConfig used by the application through storage_config= when Hive partition
granularity or compression differs from the defaults.
Both backends use the same StorageBackend interface.
import polars as pl
df = pl.DataFrame({
"timestamp": [...],
"open": [...],
"high": [...],
"low": [...],
"close": [...],
"volume": [...],
})
# Write with a hierarchical storage key
storage.write(df, "equities/daily/AAPL")The write operation:
- Adds partition columns derived from
timestamp(Hive only) - Groups data by partition values
- Writes each partition atomically (temp file + rename)
- Updates the JSON metadata manifest
All reads return a Polars LazyFrame for deferred execution:
# Read all data for a key
lf = storage.read("equities/daily/AAPL")
df = lf.collect()
# Date-filtered read (Hive prunes partitions before scanning)
from datetime import datetime
lf = storage.read(
"equities/daily/AAPL",
start_date=datetime(2024, 6, 1),
end_date=datetime(2024, 12, 31),
)
# Column projection (only read what you need)
lf = storage.read("equities/daily/AAPL", columns=["timestamp", "close", "volume"])Date ranges use half-open intervals: start_date is inclusive and end_date is
exclusive. This convention is consistent across Flat, Hive, and Chunked storage.
With Hive storage, date filters prune entire partition directories before any Parquet file is opened, giving measured 7x speedup on typical queries.
# List all stored keys
keys = storage.list_keys() # ["AAPL", "BTC-USD", ...]
# Check existence
if storage.exists("AAPL"):
...
# Delete all data for a key
storage.delete("AAPL")
# Read metadata
meta = storage.get_metadata("AAPL")
# {"last_updated": "...", "row_count": 5040, "schema": [...], ...}| Field | Default | Description |
|---|---|---|
base_path |
required | Base directory for all data |
strategy |
"hive" |
"hive" or "flat" |
compression |
"zstd" |
Parquet compression: zstd, lz4, snappy, or None |
partition_granularity |
"month" |
year, month, day, hour (Hive only) |
lock_timeout |
30 |
Seconds to wait for another writer on the same key |
metadata_tracking |
True |
JSON manifest files in .metadata/ |
Writes are always staged and published atomically. Generate profiles explicitly with the profiling API described below.
Hive storage supports chunk-based incremental updates for streaming workflows:
from datetime import datetime
# Save a new data chunk
chunk_path = storage.save_chunk(
data=new_df,
symbol="AAPL",
provider="yahoo",
start_time=datetime(2024, 12, 1),
end_time=datetime(2024, 12, 31),
)
# Merge chunk into the main combined file (deduplicates by timestamp)
new_rows = storage.update_combined_file(new_df, symbol="AAPL", provider="yahoo")
# Get latest timestamp for a symbol (to know where to resume)
latest = storage.get_latest_timestamp("AAPL", "yahoo")The ProfileMixin adds dataset profiling to any data manager class.
Profiles contain column-level statistics (dtype, null count, min/max, mean, std)
stored as JSON alongside the data.
from ml4t.data.storage import generate_profile, save_profile, load_profile
# Generate a profile from a DataFrame
profile = generate_profile(df, source="ETFDataManager")
print(profile.summary())
# Dataset Profile (generated: 2024-12-15T10:30:00)
# Rows: 25,200
# Columns: 7
# Date range: 2020-01-02 to 2024-12-13
# Column Details:
# close: Float64 (25200 unique, 0.0% null) [mean=156.3, std=42.1]
# volume: Int64 (24891 unique, 0.0% null) [mean=7.83e+07, std=4.21e+07]
# Save and load profiles
save_profile(profile, Path("data/AAPL_profile.json"))
profile = load_profile(Path("data/AAPL_profile.json"))
# Convert to DataFrame for analysis
profile_df = profile.to_dataframe()To add profiling to a custom data manager, implement the ProfileMixin:
from ml4t.data.storage import ProfileMixin
class MyDataManager(ProfileMixin):
def _get_profile_data(self) -> pl.DataFrame:
return self.load_all()
def _get_profile_data_path(self) -> Path:
return self.storage_path / "data.parquet"
def _get_profile_source_name(self) -> str:
return "MyDataManager"
# Now available:
profile = manager.generate_profile() # generates and saves
profile = manager.load_profile() # loads existingBoth backends use two mechanisms for safe concurrent access:
- Atomic writes: Data is written to a temporary file first, then renamed to the target path. This prevents readers from seeing partial writes.
- File locking: Metadata updates use
filelockto prevent corruption when multiple processes write simultaneously. Lock timeout is 10 seconds.
These are enabled by default and can be toggled via StorageConfig.