Skip to content

Commit b9ddeb8

Browse files
karlwaldmanclaude
andauthored
feat(analysis): add technical indicators module (Closes #3) (#41)
Add `client.analysis` (AnalysisResource) with SMA, EMA, RSI, MACD, Bollinger Bands, and ATR. Supports both the DataFrame helper (`with_indicators(df, indicators=[...])`, non-mutating) and direct per-Series calculation. Pure pandas/numpy implementation with no new hard dependencies; pandas remains the optional `[pandas]` extra and is guarded with the same friendly ImportError used elsewhere in the SDK. Includes full unit coverage (12 tests, analysis.py at ~87%), README documentation with examples for both usage styles, and a CHANGELOG entry. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 242c450 commit b9ddeb8

6 files changed

Lines changed: 457 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to the OilPriceAPI Python SDK will be documented in this fil
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **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.
13+
814
## [1.5.0] - 2026-02-11
915

1016
### Added

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
The official Python SDK for [OilPriceAPI](https://oilpriceapi.com) - Real-time and historical oil prices for Brent Crude, WTI, Natural Gas, and more.
1515

16-
> **📝 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.
16+
> **📝 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.
1717
1818
**Quick start:**
1919

@@ -63,6 +63,37 @@ print(f"Retrieved {len(df)} data points")
6363
print(df.head())
6464
```
6565

66+
### Technical Indicators (New in v1.9.0)
67+
68+
Add technical analysis indicators to any price DataFrame. Implemented in pure
69+
pandas/numpy, so no extra dependencies beyond the optional `[pandas]` extra.
70+
71+
```python
72+
# Get historical data
73+
df = client.prices.to_dataframe(
74+
commodity="BRENT_CRUDE_USD",
75+
start="2024-01-01",
76+
interval="daily",
77+
)
78+
79+
# Method 1: DataFrame helper (non-mutating, returns a new DataFrame)
80+
df = client.analysis.with_indicators(
81+
df,
82+
indicators=["sma_20", "sma_50", "rsi", "bollinger_bands", "macd"],
83+
)
84+
# Adds columns: sma_20, sma_50, rsi, bb_upper, bb_middle, bb_lower,
85+
# macd, macd_signal, macd_histogram
86+
print(df.tail())
87+
88+
# Method 2: Direct calculation on a Series
89+
df["sma_20"] = client.analysis.sma(df["value"], period=20)
90+
df["ema_12"] = client.analysis.ema(df["value"], period=12)
91+
df["rsi"] = client.analysis.rsi(df["value"], period=14)
92+
bands = client.analysis.bollinger_bands(df["value"], period=20, std=2)
93+
```
94+
95+
Supported indicators: SMA, EMA, RSI, MACD, Bollinger Bands, and ATR.
96+
6697
### Diesel Prices (New in v1.3.0)
6798

6899
```python
@@ -500,6 +531,7 @@ async with AsyncOilPriceAPI() as client:
500531
-**Simple API** - Intuitive methods for all endpoints
501532
-**Type Safe** - Full type hints for IDE autocomplete
502533
-**Pandas Integration** - First-class DataFrame support
534+
-**Technical Indicators** - SMA, EMA, RSI, MACD, Bollinger Bands, ATR (pure pandas/numpy)
503535
-**Price Alerts** - Automated monitoring with webhook notifications 🔔
504536
-**Diesel Prices** - State averages + station-level pricing ⛽
505537
-**Futures Contracts** - OHLC, curves, spreads, and continuous data

oilpriceapi/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from .models import DataConnectorPrice, MarketBrief
3434
from .resources.alerts import AlertsResource
35+
from .resources.analysis import AnalysisResource
3536
from .resources.analytics import AnalyticsResource
3637
from .resources.bunker_fuels import BunkerFuelsResource
3738
from .resources.commodities import CommoditiesResource
@@ -174,6 +175,7 @@ def __init__(
174175
self.rig_counts = RigCountsResource(self)
175176
self.bunker_fuels = BunkerFuelsResource(self)
176177
self.analytics = AnalyticsResource(self)
178+
self.analysis = AnalysisResource(self)
177179
self.forecasts = ForecastsResource(self)
178180
self.data_quality = DataQualityResource(self)
179181
self.drilling = DrillingIntelligenceResource(self)

oilpriceapi/resources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from .alerts import AlertsResource
8+
from .analysis import AnalysisResource
89
from .analytics import AnalyticsResource
910
from .bunker_fuels import BunkerFuelsResource
1011
from .commodities import CommoditiesResource
@@ -24,6 +25,7 @@
2425
from .webhooks import WebhooksResource
2526

2627
__all__ = [
28+
"AnalysisResource",
2729
"PricesResource",
2830
"HistoricalResource",
2931
"DieselResource",

oilpriceapi/resources/analysis.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""
2+
Analysis Resource
3+
4+
Technical analysis indicators (SMA, EMA, RSI, MACD, Bollinger Bands, ATR).
5+
6+
Implemented from scratch on top of pandas/numpy so the SDK gains no new
7+
hard dependencies. All indicator math lives in this module; pandas is only
8+
required at call time (the optional ``[pandas]`` extra), mirroring the rest
9+
of the DataFrame-aware SDK surface.
10+
"""
11+
from __future__ import annotations
12+
13+
from typing import TYPE_CHECKING, Any, List, Optional
14+
15+
if TYPE_CHECKING: # pragma: no cover - typing only
16+
import pandas as pd
17+
18+
19+
_PANDAS_ERROR = (
20+
"pandas is required for technical indicators. "
21+
"Install with: pip install oilpriceapi[pandas]"
22+
)
23+
24+
25+
def _require_pandas() -> Any:
26+
"""Import pandas lazily, raising a friendly error when it is missing."""
27+
try:
28+
import pandas as pd
29+
except ImportError:
30+
raise ImportError(_PANDAS_ERROR)
31+
if pd is None: # monkeypatched-out in tests / partial environments
32+
raise ImportError(_PANDAS_ERROR)
33+
return pd
34+
35+
36+
class AnalysisResource:
37+
"""Resource for technical analysis indicators.
38+
39+
Two usage styles are supported (see GitHub issue #3):
40+
41+
DataFrame helper::
42+
43+
df = client.analysis.with_indicators(df, indicators=["sma_20", "rsi"])
44+
45+
Direct calculation::
46+
47+
df["sma_20"] = client.analysis.sma(df["value"], period=20)
48+
df["rsi"] = client.analysis.rsi(df["value"], period=14)
49+
"""
50+
51+
def __init__(self, client: Optional[Any] = None) -> None:
52+
"""Initialize analysis resource.
53+
54+
Args:
55+
client: OilPriceAPI client instance (unused today, kept for
56+
parity with every other resource and future server-side
57+
indicator support).
58+
"""
59+
self.client = client
60+
61+
# ------------------------------------------------------------------
62+
# Direct indicators (Method 2)
63+
# ------------------------------------------------------------------
64+
def sma(self, series: "pd.Series", period: int = 20) -> "pd.Series":
65+
"""Simple Moving Average over ``period`` observations."""
66+
_require_pandas()
67+
self._validate_period(period)
68+
return series.rolling(window=period).mean()
69+
70+
def ema(self, series: "pd.Series", period: int = 20) -> "pd.Series":
71+
"""Exponential Moving Average (span = ``period``)."""
72+
_require_pandas()
73+
self._validate_period(period)
74+
return series.ewm(span=period, adjust=False).mean()
75+
76+
def rsi(self, series: "pd.Series", period: int = 14) -> "pd.Series":
77+
"""Relative Strength Index using Wilder-style smoothing.
78+
79+
Returns values in the ``[0, 100]`` range. When there are no losses
80+
in the smoothing window the RSI is pinned to ``100`` (and to ``0``
81+
when there are no gains), matching the standard definition.
82+
"""
83+
pd = _require_pandas()
84+
self._validate_period(period)
85+
86+
delta = series.diff()
87+
gain = delta.clip(lower=0)
88+
loss = -delta.clip(upper=0)
89+
90+
avg_gain = gain.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
91+
avg_loss = loss.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
92+
93+
rs = avg_gain / avg_loss
94+
rsi = 100 - (100 / (1 + rs))
95+
96+
# avg_loss == 0 -> rs == inf -> rsi already 100; guard NaN from 0/0.
97+
rsi = rsi.where(avg_loss != 0, 100.0)
98+
rsi = rsi.where(avg_gain != 0, 0.0)
99+
# Re-mask the warm-up period that has no defined average yet.
100+
warmup = avg_gain.isna() | avg_loss.isna()
101+
rsi = rsi.mask(warmup, pd.NA).astype("float64")
102+
return rsi
103+
104+
def macd(
105+
self,
106+
series: "pd.Series",
107+
fast: int = 12,
108+
slow: int = 26,
109+
signal: int = 9,
110+
) -> "pd.DataFrame":
111+
"""Moving Average Convergence Divergence.
112+
113+
Returns a DataFrame with ``macd``, ``macd_signal`` and
114+
``macd_histogram`` columns.
115+
"""
116+
pd = _require_pandas()
117+
for value in (fast, slow, signal):
118+
self._validate_period(value)
119+
if fast >= slow:
120+
raise ValueError("MACD fast period must be smaller than slow period")
121+
122+
fast_ema = series.ewm(span=fast, adjust=False).mean()
123+
slow_ema = series.ewm(span=slow, adjust=False).mean()
124+
macd_line = fast_ema - slow_ema
125+
signal_line = macd_line.ewm(span=signal, adjust=False).mean()
126+
histogram = macd_line - signal_line
127+
return pd.DataFrame(
128+
{
129+
"macd": macd_line,
130+
"macd_signal": signal_line,
131+
"macd_histogram": histogram,
132+
}
133+
)
134+
135+
def bollinger_bands(
136+
self,
137+
series: "pd.Series",
138+
period: int = 20,
139+
std: float = 2.0,
140+
) -> "pd.DataFrame":
141+
"""Bollinger Bands.
142+
143+
Returns a DataFrame with ``bb_upper``, ``bb_middle`` and
144+
``bb_lower`` columns. The middle band is the SMA; the outer bands
145+
are ``std`` rolling standard deviations away.
146+
"""
147+
pd = _require_pandas()
148+
self._validate_period(period)
149+
if std <= 0:
150+
raise ValueError("std must be positive")
151+
152+
middle = series.rolling(window=period).mean()
153+
deviation = series.rolling(window=period).std(ddof=0)
154+
upper = middle + std * deviation
155+
lower = middle - std * deviation
156+
return pd.DataFrame(
157+
{
158+
"bb_upper": upper,
159+
"bb_middle": middle,
160+
"bb_lower": lower,
161+
}
162+
)
163+
164+
def atr(
165+
self,
166+
high: "pd.Series",
167+
low: "pd.Series",
168+
close: "pd.Series",
169+
period: int = 14,
170+
) -> "pd.Series":
171+
"""Average True Range from high/low/close series."""
172+
pd = _require_pandas()
173+
self._validate_period(period)
174+
175+
prev_close = close.shift(1)
176+
true_range = pd.concat(
177+
[
178+
(high - low),
179+
(high - prev_close).abs(),
180+
(low - prev_close).abs(),
181+
],
182+
axis=1,
183+
).max(axis=1)
184+
return true_range.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
185+
186+
# ------------------------------------------------------------------
187+
# DataFrame helper (Method 1)
188+
# ------------------------------------------------------------------
189+
def with_indicators(
190+
self,
191+
df: "pd.DataFrame",
192+
indicators: List[str],
193+
column: str = "value",
194+
) -> "pd.DataFrame":
195+
"""Return a copy of ``df`` with the requested indicator columns added.
196+
197+
The input DataFrame is never mutated.
198+
199+
Args:
200+
df: DataFrame containing a price column (default ``"value"``,
201+
matching ``client.prices.to_dataframe`` output).
202+
indicators: Indicator names to add. Supported tokens:
203+
204+
* ``sma_<n>`` / ``ema_<n>`` - moving averages of period n
205+
* ``sma`` / ``ema`` - default period (20)
206+
* ``rsi`` / ``rsi_<n>`` - Relative Strength Index
207+
* ``bollinger_bands`` / ``bb`` - adds bb_upper/middle/lower
208+
* ``macd`` - adds macd/macd_signal/macd_histogram
209+
210+
column: Name of the price column to compute indicators on.
211+
212+
Returns:
213+
New DataFrame with indicator columns appended.
214+
215+
Raises:
216+
ImportError: if pandas is not installed.
217+
KeyError: if ``column`` is not present in ``df``.
218+
ValueError: if an indicator token is not recognized.
219+
"""
220+
_require_pandas()
221+
222+
if column not in df.columns:
223+
raise KeyError(
224+
f"column {column!r} not found in DataFrame; "
225+
f"available columns: {list(df.columns)}"
226+
)
227+
228+
out = df.copy()
229+
prices = out[column]
230+
231+
for indicator in indicators:
232+
name = indicator.strip().lower()
233+
234+
if name in ("bollinger_bands", "bollinger", "bb"):
235+
bands = self.bollinger_bands(prices)
236+
for col in bands.columns:
237+
out[col] = bands[col]
238+
elif name == "macd":
239+
macd_df = self.macd(prices)
240+
for col in macd_df.columns:
241+
out[col] = macd_df[col]
242+
elif name in ("rsi",) or name.startswith("rsi_"):
243+
period = self._parse_period(name, default=14)
244+
out[indicator] = self.rsi(prices, period=period)
245+
elif name == "sma" or name.startswith("sma_"):
246+
period = self._parse_period(name, default=20)
247+
out[indicator] = self.sma(prices, period=period)
248+
elif name == "ema" or name.startswith("ema_"):
249+
period = self._parse_period(name, default=20)
250+
out[indicator] = self.ema(prices, period=period)
251+
else:
252+
raise ValueError(
253+
f"Unknown indicator: {indicator!r}. Supported: sma_<n>, "
254+
"ema_<n>, rsi, rsi_<n>, bollinger_bands, macd"
255+
)
256+
257+
return out
258+
259+
# ------------------------------------------------------------------
260+
# Helpers
261+
# ------------------------------------------------------------------
262+
@staticmethod
263+
def _validate_period(period: int) -> None:
264+
if not isinstance(period, int) or period < 1:
265+
raise ValueError(f"period must be a positive integer, got {period!r}")
266+
267+
@staticmethod
268+
def _parse_period(name: str, default: int) -> int:
269+
"""Extract the trailing ``_<n>`` period from an indicator token."""
270+
if "_" not in name:
271+
return default
272+
suffix = name.rsplit("_", 1)[1]
273+
try:
274+
return int(suffix)
275+
except ValueError:
276+
raise ValueError(f"Invalid period in indicator: {name!r}")

0 commit comments

Comments
 (0)