From 4c77f1850e03afe4b9c2570c66736821e01b8ec5 Mon Sep 17 00:00:00 2001 From: FaustoS88 Date: Sat, 14 Mar 2026 11:37:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20TTL=20cache=20=E2=80=94=20interva?= =?UTF-8?q?l-aware=20in-memory=20response=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cache.py: get/set/clear/size, TTL from 30s (1m) to 86400s (1w) - registry.fetch() checks cache before providers, populates on success - OHLCV_CACHE_ENABLED=false to disable process-wide - 26 tests: TTL expiry, eviction, env toggle, registry integration - README updated with Caching section and TTL table --- CHANGELOG.md | 10 ++ README.md | 30 ++++- pyproject.toml | 2 +- src/ohlcv_router/cache.py | 74 +++++++++++ src/ohlcv_router/registry.py | 9 ++ tests/test_cache.py | 234 +++++++++++++++++++++++++++++++++++ 6 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 src/ohlcv_router/cache.py create mode 100644 tests/test_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e104f52..bdbfd75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a9cf351..3bbc891 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9e70921..85d322e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/ohlcv_router/cache.py b/src/ohlcv_router/cache.py new file mode 100644 index 0000000..d3a0833 --- /dev/null +++ b/src/ohlcv_router/cache.py @@ -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) diff --git a/src/ohlcv_router/registry.py b/src/ohlcv_router/registry.py index eda5893..0ed870f 100644 --- a/src/ohlcv_router/registry.py +++ b/src/ohlcv_router/registry.py @@ -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 @@ -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] = [] @@ -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) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..a3a6303 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,234 @@ +"""Tests for the TTL cache module and its integration with registry.fetch().""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ohlcv_router import cache +from ohlcv_router.models import Candle + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _candles(n: int = 3) -> list[Candle]: + return [ + Candle(time=1_700_000_000 + i * 86400, open=100.0, high=105.0, low=95.0, close=102.0, volume=1000.0) + for i in range(n) + ] + + +@pytest.fixture(autouse=True) +def reset_cache(): + """Ensure each test starts with an empty cache.""" + cache.clear() + yield + cache.clear() + + +# --------------------------------------------------------------------------- +# ttl_for() +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "interval,expected", + [ + ("1m", 30), + ("5m", 120), + ("15m", 300), + ("30m", 600), + ("1h", 1_800), + ("4h", 7_200), + ("1d", 14_400), + ("1w", 86_400), + ("3d", 60), # unmapped — falls back to default + ], +) +def test_ttl_for(interval: str, expected: int) -> None: + assert cache.ttl_for(interval) == expected + + +# --------------------------------------------------------------------------- +# get / set / clear +# --------------------------------------------------------------------------- + +def test_get_returns_none_on_empty_cache() -> None: + assert cache.get("BTCUSDT", "1d", 10) is None + + +def test_set_and_get_returns_candles() -> None: + candles = _candles(5) + cache.set("BTCUSDT", "1d", 5, candles) + result = cache.get("BTCUSDT", "1d", 5) + assert result is candles + + +def test_get_is_case_insensitive_on_symbol() -> None: + candles = _candles(3) + cache.set("btcusdt", "1d", 3, candles) + assert cache.get("BTCUSDT", "1d", 3) is candles + assert cache.get("btcusdt", "1d", 3) is candles + + +def test_different_limits_are_separate_entries() -> None: + a = _candles(5) + b = _candles(10) + cache.set("BTCUSDT", "1d", 5, a) + cache.set("BTCUSDT", "1d", 10, b) + assert cache.get("BTCUSDT", "1d", 5) is a + assert cache.get("BTCUSDT", "1d", 10) is b + + +def test_different_intervals_are_separate_entries() -> None: + hourly = _candles(2) + daily = _candles(3) + cache.set("AAPL", "1h", 2, hourly) + cache.set("AAPL", "1d", 3, daily) + assert cache.get("AAPL", "1h", 2) is hourly + assert cache.get("AAPL", "1d", 3) is daily + + +def test_different_symbols_are_separate_entries() -> None: + btc = _candles(2) + eth = _candles(2) + cache.set("BTCUSDT", "1d", 2, btc) + cache.set("ETHUSDT", "1d", 2, eth) + assert cache.get("BTCUSDT", "1d", 2) is btc + assert cache.get("ETHUSDT", "1d", 2) is eth + + +def test_clear_removes_all_entries() -> None: + cache.set("BTCUSDT", "1d", 10, _candles()) + cache.set("AAPL", "1h", 5, _candles()) + cache.clear() + assert cache.size() == 0 + assert cache.get("BTCUSDT", "1d", 10) is None + + +def test_size_tracks_entries() -> None: + assert cache.size() == 0 + cache.set("BTCUSDT", "1d", 10, _candles()) + assert cache.size() == 1 + cache.set("AAPL", "1d", 5, _candles()) + assert cache.size() == 2 + + +# --------------------------------------------------------------------------- +# TTL expiry +# --------------------------------------------------------------------------- + +def test_expired_entry_returns_none(monkeypatch: pytest.MonkeyPatch) -> None: + candles = _candles(3) + cache.set("BTCUSDT", "1m", 3, candles) # TTL = 30s + + # Advance monotonic clock past expiry + import time + original = time.monotonic + monkeypatch.setattr(time, "monotonic", lambda: original() + 31) + + assert cache.get("BTCUSDT", "1m", 3) is None + + +def test_expired_entry_is_evicted(monkeypatch: pytest.MonkeyPatch) -> None: + cache.set("BTCUSDT", "1m", 3, _candles()) + assert cache.size() == 1 + + import time + original = time.monotonic + monkeypatch.setattr(time, "monotonic", lambda: original() + 31) + + cache.get("BTCUSDT", "1m", 3) # triggers eviction + assert cache.size() == 0 + + +def test_non_expired_entry_still_returned(monkeypatch: pytest.MonkeyPatch) -> None: + candles = _candles(3) + cache.set("BTCUSDT", "1d", 3, candles) # TTL = 14400s + + import time + original = time.monotonic + monkeypatch.setattr(time, "monotonic", lambda: original() + 100) + + assert cache.get("BTCUSDT", "1d", 3) is candles + + +# --------------------------------------------------------------------------- +# is_enabled() +# --------------------------------------------------------------------------- + +def test_cache_enabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OHLCV_CACHE_ENABLED", raising=False) + assert cache.is_enabled() is True + + +def test_cache_disabled_via_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OHLCV_CACHE_ENABLED", "false") + assert cache.is_enabled() is False + + +def test_cache_enabled_explicitly(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OHLCV_CACHE_ENABLED", "true") + assert cache.is_enabled() is True + + +# --------------------------------------------------------------------------- +# registry.fetch() integration +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_registry_fetch_populates_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OHLCV_CACHE_ENABLED", raising=False) + candles = _candles(5) + + from ohlcv_router import registry + mock_provider = MagicMock() + mock_provider.name = "mock" + mock_provider.supports.return_value = True + mock_provider.fetch = AsyncMock(return_value=candles) + + with patch.object(registry, "pick", return_value=[mock_provider]): + result = await registry.fetch("BTCUSDT", "1d", 5) + + assert result is candles + assert cache.get("BTCUSDT", "1d", 5) is candles + + +@pytest.mark.asyncio +async def test_registry_fetch_uses_cache_on_second_call(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OHLCV_CACHE_ENABLED", raising=False) + candles = _candles(5) + + from ohlcv_router import registry + mock_provider = MagicMock() + mock_provider.name = "mock" + mock_provider.supports.return_value = True + mock_provider.fetch = AsyncMock(return_value=candles) + + with patch.object(registry, "pick", return_value=[mock_provider]): + await registry.fetch("BTCUSDT", "1d", 5) + await registry.fetch("BTCUSDT", "1d", 5) + + # Provider should only be called once — second call served from cache + assert mock_provider.fetch.call_count == 1 + + +@pytest.mark.asyncio +async def test_registry_fetch_skips_cache_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OHLCV_CACHE_ENABLED", "false") + candles = _candles(5) + + from ohlcv_router import registry + mock_provider = MagicMock() + mock_provider.name = "mock" + mock_provider.supports.return_value = True + mock_provider.fetch = AsyncMock(return_value=candles) + + with patch.object(registry, "pick", return_value=[mock_provider]): + await registry.fetch("BTCUSDT", "1d", 5) + await registry.fetch("BTCUSDT", "1d", 5) + + # Both calls hit the provider when cache is disabled + assert mock_provider.fetch.call_count == 2 + assert cache.size() == 0