diff --git a/CHANGELOG.md b/CHANGELOG.md index 39235a8..26dfdea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Analysis Resource (Technical Indicators)**: `client.analysis` with `with_indicators(df, indicators=[...])` DataFrame helper and direct methods `sma()`, `ema()`, `rsi()`, `macd()`, `bollinger_bands()`, `atr()`. Pure pandas/numpy implementation, no new dependencies. Closes #3. + ## [1.5.0] - 2026-02-11 ### Added diff --git a/README.md b/README.md index c628c56..4a5d5b4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The official Python SDK for [OilPriceAPI](https://oilpriceapi.com) - Real-time and historical oil prices for Brent Crude, WTI, Natural Gas, and more. -> **📝 Documentation Status**: This README reflects v1.5.0 features. All code examples shown are tested and working. Advanced features like technical indicators and CLI tools are planned for future releases - see our [GitHub Issues](https://github.com/OilpriceAPI/python-sdk/issues) for roadmap. +> **📝 Documentation Status**: All code examples shown are tested and working. Technical indicators are available as of v1.9.0 (see [Technical Indicators](#technical-indicators-new-in-v190)); see our [GitHub Issues](https://github.com/OilpriceAPI/python-sdk/issues) for the roadmap. **Quick start:** @@ -63,6 +63,37 @@ print(f"Retrieved {len(df)} data points") print(df.head()) ``` +### Technical Indicators (New in v1.9.0) + +Add technical analysis indicators to any price DataFrame. Implemented in pure +pandas/numpy, so no extra dependencies beyond the optional `[pandas]` extra. + +```python +# Get historical data +df = client.prices.to_dataframe( + commodity="BRENT_CRUDE_USD", + start="2024-01-01", + interval="daily", +) + +# Method 1: DataFrame helper (non-mutating, returns a new DataFrame) +df = client.analysis.with_indicators( + df, + indicators=["sma_20", "sma_50", "rsi", "bollinger_bands", "macd"], +) +# Adds columns: sma_20, sma_50, rsi, bb_upper, bb_middle, bb_lower, +# macd, macd_signal, macd_histogram +print(df.tail()) + +# Method 2: Direct calculation on a Series +df["sma_20"] = client.analysis.sma(df["value"], period=20) +df["ema_12"] = client.analysis.ema(df["value"], period=12) +df["rsi"] = client.analysis.rsi(df["value"], period=14) +bands = client.analysis.bollinger_bands(df["value"], period=20, std=2) +``` + +Supported indicators: SMA, EMA, RSI, MACD, Bollinger Bands, and ATR. + ### Diesel Prices (New in v1.3.0) ```python @@ -500,6 +531,7 @@ async with AsyncOilPriceAPI() as client: - ✅ **Simple API** - Intuitive methods for all endpoints - ✅ **Type Safe** - Full type hints for IDE autocomplete - ✅ **Pandas Integration** - First-class DataFrame support +- ✅ **Technical Indicators** - SMA, EMA, RSI, MACD, Bollinger Bands, ATR (pure pandas/numpy) - ✅ **Price Alerts** - Automated monitoring with webhook notifications 🔔 - ✅ **Diesel Prices** - State averages + station-level pricing ⛽ - ✅ **Futures Contracts** - OHLC, curves, spreads, and continuous data diff --git a/oilpriceapi/client.py b/oilpriceapi/client.py index 4a0760f..cac71af 100644 --- a/oilpriceapi/client.py +++ b/oilpriceapi/client.py @@ -32,6 +32,7 @@ ) from .models import DataConnectorPrice, MarketBrief from .resources.alerts import AlertsResource +from .resources.analysis import AnalysisResource from .resources.analytics import AnalyticsResource from .resources.bunker_fuels import BunkerFuelsResource from .resources.commodities import CommoditiesResource @@ -174,6 +175,7 @@ def __init__( self.rig_counts = RigCountsResource(self) self.bunker_fuels = BunkerFuelsResource(self) self.analytics = AnalyticsResource(self) + self.analysis = AnalysisResource(self) self.forecasts = ForecastsResource(self) self.data_quality = DataQualityResource(self) self.drilling = DrillingIntelligenceResource(self) diff --git a/oilpriceapi/resources/__init__.py b/oilpriceapi/resources/__init__.py index e68120c..efbde3a 100644 --- a/oilpriceapi/resources/__init__.py +++ b/oilpriceapi/resources/__init__.py @@ -5,6 +5,7 @@ """ from .alerts import AlertsResource +from .analysis import AnalysisResource from .analytics import AnalyticsResource from .bunker_fuels import BunkerFuelsResource from .commodities import CommoditiesResource @@ -24,6 +25,7 @@ from .webhooks import WebhooksResource __all__ = [ + "AnalysisResource", "PricesResource", "HistoricalResource", "DieselResource", diff --git a/oilpriceapi/resources/analysis.py b/oilpriceapi/resources/analysis.py new file mode 100644 index 0000000..8b3be1d --- /dev/null +++ b/oilpriceapi/resources/analysis.py @@ -0,0 +1,276 @@ +""" +Analysis Resource + +Technical analysis indicators (SMA, EMA, RSI, MACD, Bollinger Bands, ATR). + +Implemented from scratch on top of pandas/numpy so the SDK gains no new +hard dependencies. All indicator math lives in this module; pandas is only +required at call time (the optional ``[pandas]`` extra), mirroring the rest +of the DataFrame-aware SDK surface. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +if TYPE_CHECKING: # pragma: no cover - typing only + import pandas as pd + + +_PANDAS_ERROR = ( + "pandas is required for technical indicators. " + "Install with: pip install oilpriceapi[pandas]" +) + + +def _require_pandas() -> Any: + """Import pandas lazily, raising a friendly error when it is missing.""" + try: + import pandas as pd + except ImportError: + raise ImportError(_PANDAS_ERROR) + if pd is None: # monkeypatched-out in tests / partial environments + raise ImportError(_PANDAS_ERROR) + return pd + + +class AnalysisResource: + """Resource for technical analysis indicators. + + Two usage styles are supported (see GitHub issue #3): + + DataFrame helper:: + + df = client.analysis.with_indicators(df, indicators=["sma_20", "rsi"]) + + Direct calculation:: + + df["sma_20"] = client.analysis.sma(df["value"], period=20) + df["rsi"] = client.analysis.rsi(df["value"], period=14) + """ + + def __init__(self, client: Optional[Any] = None) -> None: + """Initialize analysis resource. + + Args: + client: OilPriceAPI client instance (unused today, kept for + parity with every other resource and future server-side + indicator support). + """ + self.client = client + + # ------------------------------------------------------------------ + # Direct indicators (Method 2) + # ------------------------------------------------------------------ + def sma(self, series: "pd.Series", period: int = 20) -> "pd.Series": + """Simple Moving Average over ``period`` observations.""" + _require_pandas() + self._validate_period(period) + return series.rolling(window=period).mean() + + def ema(self, series: "pd.Series", period: int = 20) -> "pd.Series": + """Exponential Moving Average (span = ``period``).""" + _require_pandas() + self._validate_period(period) + return series.ewm(span=period, adjust=False).mean() + + def rsi(self, series: "pd.Series", period: int = 14) -> "pd.Series": + """Relative Strength Index using Wilder-style smoothing. + + Returns values in the ``[0, 100]`` range. When there are no losses + in the smoothing window the RSI is pinned to ``100`` (and to ``0`` + when there are no gains), matching the standard definition. + """ + pd = _require_pandas() + self._validate_period(period) + + delta = series.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + + avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + # avg_loss == 0 -> rs == inf -> rsi already 100; guard NaN from 0/0. + rsi = rsi.where(avg_loss != 0, 100.0) + rsi = rsi.where(avg_gain != 0, 0.0) + # Re-mask the warm-up period that has no defined average yet. + warmup = avg_gain.isna() | avg_loss.isna() + rsi = rsi.mask(warmup, pd.NA).astype("float64") + return rsi + + def macd( + self, + series: "pd.Series", + fast: int = 12, + slow: int = 26, + signal: int = 9, + ) -> "pd.DataFrame": + """Moving Average Convergence Divergence. + + Returns a DataFrame with ``macd``, ``macd_signal`` and + ``macd_histogram`` columns. + """ + pd = _require_pandas() + for value in (fast, slow, signal): + self._validate_period(value) + if fast >= slow: + raise ValueError("MACD fast period must be smaller than slow period") + + fast_ema = series.ewm(span=fast, adjust=False).mean() + slow_ema = series.ewm(span=slow, adjust=False).mean() + macd_line = fast_ema - slow_ema + signal_line = macd_line.ewm(span=signal, adjust=False).mean() + histogram = macd_line - signal_line + return pd.DataFrame( + { + "macd": macd_line, + "macd_signal": signal_line, + "macd_histogram": histogram, + } + ) + + def bollinger_bands( + self, + series: "pd.Series", + period: int = 20, + std: float = 2.0, + ) -> "pd.DataFrame": + """Bollinger Bands. + + Returns a DataFrame with ``bb_upper``, ``bb_middle`` and + ``bb_lower`` columns. The middle band is the SMA; the outer bands + are ``std`` rolling standard deviations away. + """ + pd = _require_pandas() + self._validate_period(period) + if std <= 0: + raise ValueError("std must be positive") + + middle = series.rolling(window=period).mean() + deviation = series.rolling(window=period).std(ddof=0) + upper = middle + std * deviation + lower = middle - std * deviation + return pd.DataFrame( + { + "bb_upper": upper, + "bb_middle": middle, + "bb_lower": lower, + } + ) + + def atr( + self, + high: "pd.Series", + low: "pd.Series", + close: "pd.Series", + period: int = 14, + ) -> "pd.Series": + """Average True Range from high/low/close series.""" + pd = _require_pandas() + self._validate_period(period) + + prev_close = close.shift(1) + true_range = pd.concat( + [ + (high - low), + (high - prev_close).abs(), + (low - prev_close).abs(), + ], + axis=1, + ).max(axis=1) + return true_range.ewm(alpha=1 / period, min_periods=period, adjust=False).mean() + + # ------------------------------------------------------------------ + # DataFrame helper (Method 1) + # ------------------------------------------------------------------ + def with_indicators( + self, + df: "pd.DataFrame", + indicators: List[str], + column: str = "value", + ) -> "pd.DataFrame": + """Return a copy of ``df`` with the requested indicator columns added. + + The input DataFrame is never mutated. + + Args: + df: DataFrame containing a price column (default ``"value"``, + matching ``client.prices.to_dataframe`` output). + indicators: Indicator names to add. Supported tokens: + + * ``sma_`` / ``ema_`` - moving averages of period n + * ``sma`` / ``ema`` - default period (20) + * ``rsi`` / ``rsi_`` - Relative Strength Index + * ``bollinger_bands`` / ``bb`` - adds bb_upper/middle/lower + * ``macd`` - adds macd/macd_signal/macd_histogram + + column: Name of the price column to compute indicators on. + + Returns: + New DataFrame with indicator columns appended. + + Raises: + ImportError: if pandas is not installed. + KeyError: if ``column`` is not present in ``df``. + ValueError: if an indicator token is not recognized. + """ + _require_pandas() + + if column not in df.columns: + raise KeyError( + f"column {column!r} not found in DataFrame; " + f"available columns: {list(df.columns)}" + ) + + out = df.copy() + prices = out[column] + + for indicator in indicators: + name = indicator.strip().lower() + + if name in ("bollinger_bands", "bollinger", "bb"): + bands = self.bollinger_bands(prices) + for col in bands.columns: + out[col] = bands[col] + elif name == "macd": + macd_df = self.macd(prices) + for col in macd_df.columns: + out[col] = macd_df[col] + elif name in ("rsi",) or name.startswith("rsi_"): + period = self._parse_period(name, default=14) + out[indicator] = self.rsi(prices, period=period) + elif name == "sma" or name.startswith("sma_"): + period = self._parse_period(name, default=20) + out[indicator] = self.sma(prices, period=period) + elif name == "ema" or name.startswith("ema_"): + period = self._parse_period(name, default=20) + out[indicator] = self.ema(prices, period=period) + else: + raise ValueError( + f"Unknown indicator: {indicator!r}. Supported: sma_, " + "ema_, rsi, rsi_, bollinger_bands, macd" + ) + + return out + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _validate_period(period: int) -> None: + if not isinstance(period, int) or period < 1: + raise ValueError(f"period must be a positive integer, got {period!r}") + + @staticmethod + def _parse_period(name: str, default: int) -> int: + """Extract the trailing ``_`` period from an indicator token.""" + if "_" not in name: + return default + suffix = name.rsplit("_", 1)[1] + try: + return int(suffix) + except ValueError: + raise ValueError(f"Invalid period in indicator: {name!r}") diff --git a/tests/unit/test_analysis_resource.py b/tests/unit/test_analysis_resource.py new file mode 100644 index 0000000..4195ca1 --- /dev/null +++ b/tests/unit/test_analysis_resource.py @@ -0,0 +1,138 @@ +""" +Unit tests for AnalysisResource (technical indicators). + +Covers GitHub issue #3: Technical Indicators Module. + +These tests require pandas/numpy and are skipped automatically when the +optional ``[pandas]`` extra is not installed (mirrors the existing +``test_historical_resource.py`` pattern). +""" + +import sys + +import pytest + +from oilpriceapi import OilPriceAPI + +pd = pytest.importorskip("pandas") + + +@pytest.fixture +def client(): + """Create a test client.""" + return OilPriceAPI(api_key="test_key") + + +@pytest.fixture +def price_series(): + """A deterministic, gently trending price series for indicator math.""" + values = [ + 100, 101, 102, 101, 103, 104, 105, 104, 106, 107, + 108, 109, 110, 109, 111, 112, 113, 112, 114, 115, + 116, 117, 118, 117, 119, 120, 121, 120, 122, 123, + ] + idx = pd.date_range("2024-01-01", periods=len(values), freq="D") + return pd.Series(values, index=idx, dtype="float64", name="value") + + +@pytest.fixture +def price_df(price_series): + """DataFrame shaped like ``client.prices.to_dataframe`` output.""" + return pd.DataFrame({"value": price_series}) + + +class TestAnalysisResourceWiring: + """The resource must be wired onto the client like every other resource.""" + + def test_client_exposes_analysis_resource(self, client): + assert hasattr(client, "analysis") + assert client.analysis is not None + + +class TestDirectIndicators: + """Method 2 from the issue: direct calculation on a Series.""" + + def test_sma_matches_pandas_rolling_mean(self, client, price_series): + result = client.analysis.sma(price_series, period=20) + expected = price_series.rolling(window=20).mean() + pd.testing.assert_series_equal(result, expected, check_names=False) + assert result.isna().sum() == 19 + assert not pd.isna(result.iloc[19]) + + def test_ema_matches_pandas_ewm(self, client, price_series): + result = client.analysis.ema(price_series, period=10) + expected = price_series.ewm(span=10, adjust=False).mean() + pd.testing.assert_series_equal(result, expected, check_names=False) + + def test_rsi_bounded_0_to_100(self, client, price_series): + result = client.analysis.rsi(price_series, period=14) + defined = result.dropna() + assert len(defined) > 0 + assert (defined >= 0).all() + assert (defined <= 100).all() + + def test_rsi_all_gains_is_100(self, client): + rising = pd.Series([float(i) for i in range(1, 30)]) + result = client.analysis.rsi(rising, period=14) + assert result.dropna().iloc[-1] == pytest.approx(100.0) + + def test_bollinger_bands_returns_three_bands(self, client, price_series): + bands = client.analysis.bollinger_bands(price_series, period=20, std=2) + assert set(["bb_upper", "bb_middle", "bb_lower"]).issubset(bands.columns) + valid = bands.dropna() + assert len(valid) > 0 + assert (valid["bb_upper"] >= valid["bb_middle"]).all() + assert (valid["bb_middle"] >= valid["bb_lower"]).all() + sma = price_series.rolling(window=20).mean() + pd.testing.assert_series_equal( + bands["bb_middle"], sma, check_names=False + ) + + +class TestWithIndicators: + """Method 1 from the issue: DataFrame helper.""" + + def test_with_indicators_is_non_mutating(self, client, price_df): + before = price_df.copy() + client.analysis.with_indicators(price_df, indicators=["sma_20"]) + pd.testing.assert_frame_equal(price_df, before) + + def test_with_indicators_adds_requested_columns(self, client, price_df): + out = client.analysis.with_indicators( + price_df, + indicators=["sma_20", "sma_50", "rsi", "bollinger_bands", "macd"], + ) + for col in ["sma_20", "sma_50", "rsi", "bb_upper", "bb_middle", "bb_lower"]: + assert col in out.columns, f"missing column {col}" + assert "macd" in out.columns + assert "macd_signal" in out.columns + + def test_with_indicators_sma_values_correct(self, client, price_df): + out = client.analysis.with_indicators(price_df, indicators=["sma_20"]) + expected = price_df["value"].rolling(window=20).mean() + pd.testing.assert_series_equal( + out["sma_20"], expected, check_names=False + ) + + def test_with_indicators_unknown_indicator_raises(self, client, price_df): + with pytest.raises(ValueError): + client.analysis.with_indicators(price_df, indicators=["not_real"]) + + def test_with_indicators_custom_value_column(self, client, price_series): + df = pd.DataFrame({"price": price_series}) + out = client.analysis.with_indicators( + df, indicators=["sma_20"], column="price" + ) + assert "sma_20" in out.columns + + +class TestPandasGuard: + """The optional-dependency guard mirrors the rest of the SDK.""" + + def test_with_indicators_without_pandas_raises_importerror( + self, client, price_df, monkeypatch + ): + monkeypatch.setitem(sys.modules, "pandas", None) + with pytest.raises(ImportError) as exc_info: + client.analysis.with_indicators(price_df, indicators=["sma_20"]) + assert "pandas is required" in str(exc_info.value)