Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

## [0.1.2] — 2026-03-14

### Added
- In-memory TTL cache in `ohlcv_router.cache` — interval-aware expiry (30s for `1m` up to 24h for `1w`)
- Cache integrated into `registry.fetch()` — hit avoids all provider calls, miss populates on success
- `OHLCV_CACHE_ENABLED=false` env var to disable cache process-wide
- `cache.clear()` and `cache.size()` for testing and CLI tooling
- 26 cache tests covering TTL expiry, eviction, env toggle, and registry integration
- README Caching section with TTL table and usage examples

## [0.1.1] — 2026-03-14

### Changed
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ EURUSD → yfinance → Finnhub

- **Multi-provider** — Binance, CoinGecko, Kraken, KuCoin, yfinance, Tiingo, Finnhub with automatic fallback
- **Auto-routing** — asset class detection picks the right provider chain per symbol
- **TTL cache** — in-memory response cache with interval-aware expiry (30s for `1m`, 4h for `1d`, 24h for `1w`)
- **Async** — built on `asyncio` / `aiohttp`, no blocking calls
- **Typed** — full type annotations, `py.typed` marker included
- **CLI** — `ohlcv fetch BTCUSDT 1d 30` out of the box
Expand Down Expand Up @@ -97,6 +98,33 @@ Kraken requires no API key. Public REST API for all listed crypto pairs. Support

KuCoin requires no API key. Public REST API supporting all standard intervals from `1m` to `1w`. Returns up to 1500 bars per request. Uses `BASE-QUOTE` symbol format internally (e.g. `BTC-USDT`).

## Caching

Responses are cached in memory with interval-aware TTLs by default:

| Interval | Cache TTL |
|----------|-----------|
| `1m` | 30 s |
| `5m` | 2 min |
| `15m` | 5 min |
| `1h` | 30 min |
| `4h` | 2 h |
| `1d` | 4 h |
| `1w` | 24 h |

Disable caching for a process by setting:

```bash
OHLCV_CACHE_ENABLED=false ohlcv fetch BTCUSDT 1d 10
```

Or in Python:

```python
import os
os.environ["OHLCV_CACHE_ENABLED"] = "false"
```

## Examples

See [`examples/`](examples/) for runnable scripts:
Expand All @@ -109,11 +137,11 @@ See [`examples/`](examples/) for runnable scripts:
**Done**
- Binance, CoinGecko, Kraken, KuCoin, yfinance, Tiingo, Finnhub providers
- CLI tool: `ohlcv fetch BTCUSDT 1d 100`
- TTL cache with interval-aware expiry
- Session reuse, structured logging, full type annotations
- Published on PyPI: `pip install ohlcv-router`

**Planned**
- Response caching (TTL-based, in-memory)
- OKX and Bybit providers
- Async context manager support

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ohlcv-router"
version = "0.1.1"
version = "0.1.2"
description = "Async Python library for fetching OHLCV market data from multiple free providers"
readme = "README.md"
license = { text = "MIT" }
Expand Down
74 changes: 74 additions & 0 deletions src/ohlcv_router/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""In-memory TTL cache for OHLCV fetch results.

Caches provider responses keyed by (symbol, interval, limit) with
interval-aware expiry times — short intervals expire quickly, daily/weekly
bars are cached for hours.

The cache is module-level (process-scoped). Disable it entirely by setting
the environment variable ``OHLCV_CACHE_ENABLED=false``.
"""

from __future__ import annotations

import os
import time
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from ohlcv_router.models import Candle

# TTL in seconds, keyed by interval string
_TTL: dict[str, int] = {
"1m": 30,
"5m": 120,
"15m": 300,
"30m": 600,
"1h": 1_800,
"4h": 7_200,
"1d": 14_400,
"1w": 86_400,
}

_DEFAULT_TTL = 60 # fallback for unmapped intervals

# Internal store: key → (expires_at, candles)
_store: dict[tuple[str, str, int], tuple[float, list[Candle]]] = {}


def is_enabled() -> bool:
"""Return True unless OHLCV_CACHE_ENABLED is explicitly set to 'false'."""
return os.getenv("OHLCV_CACHE_ENABLED", "true").lower() != "false"


def ttl_for(interval: str) -> int:
"""Return the TTL in seconds for a given interval."""
return _TTL.get(interval, _DEFAULT_TTL)


def get(symbol: str, interval: str, limit: int) -> list[Candle] | None:
"""Return cached candles if present and not expired, else None."""
key = (symbol.upper(), interval, limit)
entry = _store.get(key)
if entry is None:
return None
expires_at, candles = entry
if time.monotonic() > expires_at:
del _store[key]
return None
return candles


def set(symbol: str, interval: str, limit: int, candles: list[Candle]) -> None:
"""Store candles in the cache with the appropriate TTL for the interval."""
key = (symbol.upper(), interval, limit)
_store[key] = (time.monotonic() + ttl_for(interval), candles)


def clear() -> None:
"""Evict all cached entries. Useful in tests and CLI resets."""
_store.clear()


def size() -> int:
"""Return the number of entries currently in the cache."""
return len(_store)
9 changes: 9 additions & 0 deletions src/ohlcv_router/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import logging
import re

from ohlcv_router import cache
from ohlcv_router.models import Candle
from ohlcv_router.providers.base import OHLCVProvider

Expand Down Expand Up @@ -132,6 +133,12 @@ async def fetch(
interval: Bar interval — ``1m``, ``5m``, ``15m``, ``1h``, ``4h``, ``1d``, ``1w``.
limit: Number of bars to return (most recent, oldest-first).
"""
if cache.is_enabled():
cached = cache.get(symbol, interval, limit)
if cached is not None:
logger.debug("cache hit for %s %s (limit=%d)", symbol, interval, limit)
return cached

chain = pick(symbol)
tried: list[str] = []

Expand All @@ -151,6 +158,8 @@ async def fetch(
symbol,
interval,
)
if cache.is_enabled():
cache.set(symbol, interval, limit, result)
return result

tried.append(provider.name)
Expand Down
Loading
Loading