mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
[V5] openbb-technical: Refactor for V5 & Add More Quick Win Endpoints (#7502)
* refactor openbb-technical for v5 * forward refs * linting * no python 3.15 in test matrix * reame * ty * move all params to body --------- Co-authored-by: deeleeramone <>
This commit is contained in:
17
.github/workflows/general-linting.yml
vendored
17
.github/workflows/general-linting.yml
vendored
@@ -62,6 +62,23 @@ jobs:
|
||||
pip install ./openbb_platform/extensions/devtools
|
||||
- name: Install openbb-core (so ty can resolve runtime deps)
|
||||
run: pip install -e "./openbb_platform/core[pandas]" numpy
|
||||
- name: Install changed packages (so ty resolves their declared deps)
|
||||
if: env.diff_files != ''
|
||||
run: |
|
||||
# Install each touched package so ty has the package's declared
|
||||
# third-party deps (pandas-ta, scipy, statsmodels, etc.) available
|
||||
# for type resolution. Optional cross-package peers (e.g.
|
||||
# openbb_charting) that aren't installed surface as `warn`-level
|
||||
# diagnostics under the repo-root ty.toml rather than build errors.
|
||||
touched=$(echo "${{ env.diff_files }}" | tr ' ' '\n' \
|
||||
| grep -oE '^(openbb_platform/(extensions|providers|obbject_extensions)|cli)/[^/]+' \
|
||||
| sort -u)
|
||||
for pkg in $touched; do
|
||||
if [ -f "$pkg/pyproject.toml" ]; then
|
||||
echo "Installing $pkg"
|
||||
pip install -e "./$pkg" || echo "skip $pkg (install failed)"
|
||||
fi
|
||||
done
|
||||
- run: codespell --ignore-words=.codespell.ignore --skip="$(tr '\n' ',' < .codespell.skip | sed 's/,$//')" --quiet-level=2
|
||||
- run: |
|
||||
# Run linters for openbb_platform | cli
|
||||
|
||||
175
.github/workflows/publish-openbb-technical.yml
vendored
Normal file
175
.github/workflows/publish-openbb-technical.yml
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
name: 📦 publish openbb-technical
|
||||
|
||||
# Triggers exclusively on the merge of a ``release/openbb-technical-v*``
|
||||
# branch into ``develop``. Four sequential jobs — version-check,
|
||||
# build, test, publish — with PyPI OIDC trusted publishing.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop, v5]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
name: Verify version bump
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/openbb-technical-v')) || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.parse.outputs.version }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
|
||||
- name: Parse [project].version from pyproject.toml
|
||||
id: parse
|
||||
run: |
|
||||
VERSION=$(grep -m1 -E '^version[[:space:]]*=' pyproject.toml | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::Could not parse [project].version from pyproject.toml."
|
||||
exit 1
|
||||
fi
|
||||
echo "Local version: $VERSION"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail if version already exists on PyPI
|
||||
env:
|
||||
VERSION: ${{ steps.parse.outputs.version }}
|
||||
run: |
|
||||
STATUS=$(curl -sS -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/openbb-technical/${VERSION}/json")
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "::error::openbb-technical ${VERSION} is already published on PyPI."
|
||||
echo "Bump openbb_platform/extensions/technical/pyproject.toml [project].version and re-merge a fresh release branch."
|
||||
exit 1
|
||||
;;
|
||||
404)
|
||||
echo "openbb-technical ${VERSION} is not yet on PyPI — proceeding."
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unexpected status $STATUS from PyPI JSON API. Aborting to avoid a silent skip."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Verify release branch name matches pyproject version
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
VERSION: ${{ steps.parse.outputs.version }}
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
EXPECTED="release/openbb-technical-v${VERSION}"
|
||||
if [ "$BRANCH" != "$EXPECTED" ]; then
|
||||
echo "::error::Release branch '${BRANCH}' does not match pyproject version '${VERSION}'."
|
||||
echo "Expected branch name: '${EXPECTED}'."
|
||||
echo "Either rename the release branch or update [project].version in pyproject.toml so they agree."
|
||||
exit 1
|
||||
fi
|
||||
echo "Branch '${BRANCH}' matches pyproject version '${VERSION}'."
|
||||
|
||||
build:
|
||||
name: Build sdist + wheel
|
||||
needs: version-check
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: python -m pip install --upgrade pip uv
|
||||
|
||||
- name: Build sdist + wheel
|
||||
run: uv build --no-sources
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openbb-technical-dist
|
||||
path: openbb_platform/extensions/technical/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
test:
|
||||
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-latest, python_version: "3.10" }
|
||||
- { os: ubuntu-latest, python_version: "3.11" }
|
||||
- { os: ubuntu-latest, python_version: "3.12" }
|
||||
- { os: ubuntu-latest, python_version: "3.13" }
|
||||
- { os: ubuntu-latest, python_version: "3.14" }
|
||||
- { os: macos-latest, python_version: "3.10" }
|
||||
- { os: macos-latest, python_version: "3.14" }
|
||||
- { os: windows-latest, python_version: "3.10" }
|
||||
- { os: windows-latest, python_version: "3.14" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
allow-prereleases: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openbb-technical-dist
|
||||
path: openbb_platform/extensions/technical/dist
|
||||
|
||||
- run: python -m pip install --upgrade pip uv
|
||||
|
||||
- name: Install openbb-technical from source (+ dev group)
|
||||
# Source install via ``-e .`` reads ``[tool.uv.sources]`` and
|
||||
# resolves ``openbb-core[pandas]`` from the sibling tree at
|
||||
# ``../../core``. Installing the wheel directly would fail
|
||||
# dependency resolution until ``openbb-core>=2.0.0`` ships to
|
||||
# PyPI; the wheel is still the artifact uploaded to PyPI by
|
||||
# the ``publish`` job — this matrix validates the source the
|
||||
# wheel was built from.
|
||||
run: |
|
||||
uv pip install --system -e .
|
||||
uv pip install --system --group dev
|
||||
|
||||
- name: Run unit tests
|
||||
run: pytest tests --cov=openbb_technical --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi-openbb-technical
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openbb-technical-dist
|
||||
path: dist
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
139
.github/workflows/test-unit-openbb-technical.yml
vendored
Normal file
139
.github/workflows/test-unit-openbb-technical.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
name: 🧪 test openbb-technical
|
||||
|
||||
# PR-test for the openbb-technical extension. Triggers when a PR touches
|
||||
# any file under ``openbb_platform/extensions/technical/`` or this
|
||||
# workflow itself.
|
||||
#
|
||||
# Build-once, test-many: a single ``build`` job produces sdist + wheel,
|
||||
# then ``lint`` and ``test`` both consume the wheel artifact. Tests
|
||||
# install from source (``-e .``) so ``[tool.uv.sources]`` resolves
|
||||
# ``openbb-core[pandas]`` against the sibling repo path — the
|
||||
# ``openbb-core>=2.0.0`` declared in the wheel is not satisfiable from
|
||||
# PyPI until that version ships.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop, main, v5]
|
||||
paths:
|
||||
- "openbb_platform/extensions/technical/**"
|
||||
- ".github/workflows/test-unit-openbb-technical.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-openbb-technical-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build sdist + wheel
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: python -m pip install --upgrade pip uv
|
||||
|
||||
- name: Build sdist + wheel
|
||||
run: uv build --no-sources
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openbb-technical-dist
|
||||
path: openbb_platform/extensions/technical/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
lint:
|
||||
name: Lint (ruff + ty)
|
||||
# ``needs: build`` keeps lint dependent on a clean build. Lint runs
|
||||
# on the source tree (not the wheel) and uses the editable install
|
||||
# so ``ty`` sees real types from openbb-core / pandas-ta / scipy.
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- run: python -m pip install --upgrade pip uv
|
||||
|
||||
- name: Install openbb-technical from source (+ dev group)
|
||||
# ``-e .`` reads ``[tool.uv.sources]`` and resolves openbb-core
|
||||
# against the sibling source tree. ``--group dev`` installs
|
||||
# openbb-devtools (ruff, ty, pytest-asyncio, etc.).
|
||||
run: |
|
||||
uv pip install --system -e .
|
||||
uv pip install --system --group dev
|
||||
|
||||
- name: ruff check
|
||||
run: ruff check openbb_technical tests integration
|
||||
|
||||
- name: ruff format --check
|
||||
run: ruff format --check openbb_technical tests integration
|
||||
|
||||
- name: ty check
|
||||
run: ty check openbb_technical
|
||||
|
||||
test:
|
||||
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: ubuntu-latest, python_version: "3.10" }
|
||||
- { os: ubuntu-latest, python_version: "3.11" }
|
||||
- { os: ubuntu-latest, python_version: "3.12" }
|
||||
- { os: ubuntu-latest, python_version: "3.13" }
|
||||
- { os: ubuntu-latest, python_version: "3.14" }
|
||||
- { os: macos-latest, python_version: "3.10" }
|
||||
- { os: macos-latest, python_version: "3.14" }
|
||||
- { os: windows-latest, python_version: "3.10" }
|
||||
- { os: windows-latest, python_version: "3.14" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: openbb_platform/extensions/technical
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
allow-prereleases: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openbb-technical-dist
|
||||
path: openbb_platform/extensions/technical/dist
|
||||
|
||||
- run: python -m pip install --upgrade pip uv
|
||||
|
||||
- name: Install openbb-technical from source (+ dev group)
|
||||
# Source install via ``-e .`` reads ``[tool.uv.sources]`` and
|
||||
# resolves openbb-core[pandas] from the sibling tree at
|
||||
# ``../../core``. Installing the wheel directly would fail
|
||||
# dependency resolution until openbb-core>=2.0.0 ships to PyPI.
|
||||
run: |
|
||||
uv pip install --system -e .
|
||||
uv pip install --system --group dev
|
||||
|
||||
- name: Run unit tests
|
||||
run: pytest tests --cov=openbb_technical --cov-report=term-missing --cov-fail-under=100
|
||||
68
openbb_platform/extensions/technical/README.md
vendored
68
openbb_platform/extensions/technical/README.md
vendored
@@ -2,9 +2,7 @@
|
||||
|
||||
This extension provides Technical Analysis tools for the OpenBB Platform.
|
||||
|
||||
Features of the extension include various indicators and oscillators.
|
||||
|
||||
This extension works nicely with a companion `openbb-charting` extension
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -14,4 +12,68 @@ To install the extension, run the following command in this folder:
|
||||
pip install openbb-technical
|
||||
```
|
||||
|
||||
Documentation available [here](https://docs.openbb.co/platform/developer_guide/contributing).
|
||||
Documentation available [here](https://docs.openbb.co/odp/python/extensions/data-processing/technical)
|
||||
|
||||
|
||||
|
||||
| Endpoint | Category | Description |
|
||||
|---|---|---|
|
||||
| `bbands` | overlay | Bollinger Bands — mean +/- N standard deviations. |
|
||||
| `dema` | overlay | Double-smoothed exponential moving average. |
|
||||
| `donchian` | overlay | Donchian channel — rolling high/low envelope. |
|
||||
| `ema` | overlay | Exponential moving average. |
|
||||
| `frama` | overlay | Fractal Adaptive Moving Average — speed varies with fractal dimension. |
|
||||
| `hma` | overlay | Hull moving average — reduced lag. |
|
||||
| `ichimoku` | overlay | Ichimoku Cloud — five-line trend system. |
|
||||
| `kama` | overlay | Kaufman Adaptive Moving Average — speed varies with efficiency. |
|
||||
| `kc` | overlay | Keltner Channels — ATR-scaled envelope around an EMA. |
|
||||
| `sma` | overlay | Simple moving average. |
|
||||
| `supertrend` | overlay | SuperTrend — ATR-anchored trailing band. |
|
||||
| `tema` | overlay | Triple-smoothed exponential moving average. |
|
||||
| `vwma` | overlay | Volume-weighted moving average. |
|
||||
| `wma` | overlay | Weighted moving average — linear weights. |
|
||||
| `zlma` | overlay | Zero-lag exponential moving average. |
|
||||
| `awesome_oscillator` | oscillator | Bill Williams' AO — 5/34 SMA difference of midpoints. |
|
||||
| `cci` | oscillator | Commodity Channel Index — deviation from typical-price MA. |
|
||||
| `cg` | oscillator | Center of Gravity oscillator (Ehlers). |
|
||||
| `fisher` | oscillator | Fisher Transform — Gaussian-shaped oscillator. |
|
||||
| `mfi` | oscillator | Money Flow Index — volume-weighted RSI. |
|
||||
| `rsi` | oscillator | Relative Strength Index — momentum oscillator (0-100). |
|
||||
| `stoch` | oscillator | Stochastic Oscillator — %K and %D bounded 0-100. |
|
||||
| `trix` | oscillator | Triple-smoothed rate of change — noise-filtered momentum. |
|
||||
| `ultimate_oscillator` | oscillator | Three-window weighted momentum oscillator. |
|
||||
| `williams_r` | oscillator | Williams %R — momentum bounded -100 to 0. |
|
||||
| `adx` | trend | Average Directional Index — trend strength (0-100). |
|
||||
| `aroon` | trend | Aroon Up/Down — bars since highest high / lowest low. |
|
||||
| `choppiness` | trend | Choppiness Index — trending vs. ranging classifier. |
|
||||
| `di` | trend | Directional Indicators (+DI, -DI) — bullish/bearish pressure. |
|
||||
| `macd` | trend | MACD — fast/slow EMA difference plus signal line and histogram. |
|
||||
| `ad` | volume | Accumulation/Distribution Line — volume weighted by close-location. |
|
||||
| `adosc` | volume | Chaikin A/D Oscillator — fast EMA minus slow EMA of A/D. |
|
||||
| `obv` | volume | On-Balance Volume — cumulative signed volume. |
|
||||
| `vwap` | volume | Volume-Weighted Average Price, anchored. |
|
||||
| `atr` | volatility | Average True Range — Wilder's volatility measure. |
|
||||
| `cones` | volatility | Volatility cones — per-window realised vs. historical band. |
|
||||
| `realized_volatility` | volatility | Annualised rolling realised volatility (one of six estimators). |
|
||||
| `realized_volatility_compare` | volatility | All six realised-volatility estimators side by side. |
|
||||
| `demark` | structure | DeMark sequential — 9-bar setup count for exhaustion. |
|
||||
| `fib` | structure | Fibonacci retracement levels between a swing high and low. |
|
||||
| `pivot_points` | structure | Pivot-point support/resistance under multiple methods. |
|
||||
| `autocorrelation` | stats | ACF + PACF with significance bands. |
|
||||
| `clenow` | stats | Clenow Volatility-Adjusted Momentum (R^2 * regression slope). |
|
||||
| `drawdown` | stats | Cumulative-return drawdown plus running peak and duration. |
|
||||
| `hurst` | stats | Hurst exponent (R/S and DFA) — long-memory diagnostic. |
|
||||
| `returns_stats` | stats | Distributional and risk-adjusted return statistics. |
|
||||
| `stationarity` | stats | ADF + KPSS unit-root and stationarity tests. |
|
||||
| `breakouts` | signal | Channel-breakout events (Donchian or Bollinger). |
|
||||
| `candlestick_patterns` | signal | Single- and two-bar candlestick pattern detection. |
|
||||
| `crossovers` | signal | Fast/slow moving-average crossover events. |
|
||||
| `divergences` | signal | Bullish/bearish/hidden divergences between price and an oscillator. |
|
||||
| `oscillator_signals` | signal | Oscillator overbought/oversold regime + crossing events. |
|
||||
| `regime` | signal | Trend-strength regime classification + transitions. |
|
||||
| `correlation` | multi | Rolling pairwise correlation of returns across symbols. |
|
||||
| `correlation_matrix` | multi | Snapshot correlation matrix across every symbol. |
|
||||
| `indicators` | multi | Programmatic catalog of all registered endpoints. |
|
||||
| `multi` | multi | Run many indicators on one series and merge by date. |
|
||||
| `relative_rotation` | multi | JdK RS-Ratio + RS-Momentum vs. a benchmark (RRG). |
|
||||
| `screen` | multi | Filter a multi-symbol basket by indicator-driven predicates. |
|
||||
|
||||
32
openbb_platform/extensions/technical/docs/README.md
vendored
Normal file
32
openbb_platform/extensions/technical/docs/README.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# openbb-technical Documentation
|
||||
|
||||
The technical extension exposes ~60 endpoints organised into four families:
|
||||
|
||||
| Family | What it does | URL prefix |
|
||||
|---|---|---|
|
||||
| `indicators/*` | Single-symbol overlays, oscillators, trend, volume, volatility, structure, statistics | `/` (root) |
|
||||
| `signals/*` | Event detectors: crossovers, divergences, breakouts, candlestick patterns, regime | `/signals/` |
|
||||
| `multi/*` | Multi-symbol correlation, screening, relative-rotation, multi-indicator composition, catalog | `/` (root, except `correlation_matrix` and `screen` which expose `multi.*`) |
|
||||
| `relative_rotation` | JdK RS-Ratio + RS-Momentum for a benchmark-anchored basket | `/` (root) |
|
||||
|
||||
Every endpoint is reachable through three transports:
|
||||
|
||||
1. **Python**: `from openbb import obb; obb.technical.<endpoint>(...)`
|
||||
2. **HTTP**: `POST http://localhost:8000/api/v1/technical/<endpoint>` (run `openbb-api` to start the server)
|
||||
3. **CLI**: `openbb -i` then `/technical/<endpoint>`
|
||||
|
||||
## Where to start
|
||||
|
||||
- **First time?** [getting-started.md](./getting-started.md) shows installation, the three transports, and a 5-line example for each.
|
||||
- **Building a strategy?** [user-guides/01-indicators.md](./user-guides/01-indicators.md) covers single-symbol indicator workflows.
|
||||
- **Want trade signals?** [user-guides/02-signals.md](./user-guides/02-signals.md) covers the signals subrouter.
|
||||
- **Working with a basket?** [user-guides/03-multi-symbol.md](./user-guides/03-multi-symbol.md) covers correlation, screening, and Relative Rotation Graphs.
|
||||
- **Composing pipelines?** [user-guides/04-composition-and-discovery.md](./user-guides/04-composition-and-discovery.md) covers the `multi` and `indicators` (catalog) endpoints.
|
||||
- **Looking up a specific endpoint?** [api-reference.md](./api-reference.md) lists every endpoint with its parameters, output columns, and a one-line description.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Input**: every data-consuming endpoint accepts the same shapes — `list[Data]`, `list[dict]`, `pandas.DataFrame`, `pandas.Series`, or `numpy.ndarray`. The library converts between them automatically.
|
||||
- **Output**: every endpoint returns an `OBBject`. The numeric payload is on `.results` as a list of typed Pydantic models.
|
||||
- **Validation**: every endpoint validates its inputs through a `XxxQueryParams` Pydantic class and emits its rows through a `XxxData` (or `XxxEvent`/`XxxSignal`) class. The class docstrings carry full NumPy-format `Parameters` and `Returns` sections.
|
||||
- **Discovery**: the `indicators` endpoint introspects the registered router and returns a structured catalog — useful for building UI pickers, OpenAPI clients, or LLM tool-calls without hard-coding endpoint names.
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Regenerate ``docs/api-reference.md`` from the live router catalog.
|
||||
|
||||
Run from the extension root:
|
||||
|
||||
python docs/_generate_api_reference.py
|
||||
|
||||
The reference is auto-generated so that any new or modified endpoint is
|
||||
reflected after a single command, without hand-editing the markdown.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from openbb import obb
|
||||
|
||||
_CLASS_PATTERN = re.compile(r"<class '([^']+)'>")
|
||||
|
||||
CATEGORY_ORDER: list[tuple[str, str, str]] = [
|
||||
(
|
||||
"overlay",
|
||||
"Overlays",
|
||||
"Price-smoothing and envelope indicators plotted on the same axis as price.",
|
||||
),
|
||||
("oscillator", "Oscillators", "Bounded momentum indicators."),
|
||||
("trend", "Trend", "Direction and strength of the prevailing move."),
|
||||
("volume", "Volume", "Volume-weighted indicators."),
|
||||
(
|
||||
"volatility",
|
||||
"Volatility",
|
||||
"Realised-volatility estimators and the ATR family.",
|
||||
),
|
||||
("structure", "Structure", "Price-action support/resistance levels."),
|
||||
("stats", "Statistics", "Distributional and time-series diagnostics."),
|
||||
(
|
||||
"signal",
|
||||
"Signals",
|
||||
"Event detectors. Reachable under the `/signals/` URL prefix; in Python, via `obb.technical.signals.<name>`.",
|
||||
),
|
||||
(
|
||||
"multi",
|
||||
"Multi-Symbol and Utility",
|
||||
"Cross-sectional, composition, and discovery endpoints.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def type_str(t) -> str:
|
||||
"""Render a Python type for the docs table."""
|
||||
if t is None:
|
||||
return ""
|
||||
s = str(t) if not isinstance(t, type) else t.__name__
|
||||
s = _CLASS_PATTERN.sub(lambda m: m.group(1).rsplit(".", 1)[-1], s)
|
||||
s = s.replace("typing.", "").replace("datetime.", "")
|
||||
return s.replace("|", "\\|")
|
||||
|
||||
|
||||
def constraints_str(c: dict | None) -> str:
|
||||
if not c:
|
||||
return ""
|
||||
return ", ".join(f"`{k}={v}`" for k, v in c.items())
|
||||
|
||||
|
||||
def cell(s: str | None) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s.replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def render() -> str:
|
||||
# ``obb.technical`` is generated at runtime by the static-package builder
|
||||
# and not visible to static type checkers.
|
||||
catalog = obb.technical.indicators(category="all").results # ty: ignore[unresolved-attribute]
|
||||
entries = catalog.indicators
|
||||
by_cat: dict[str, list] = {}
|
||||
for entry in entries:
|
||||
by_cat.setdefault(entry.category, []).append(entry)
|
||||
|
||||
out: list[str] = []
|
||||
out.append("# API Reference")
|
||||
out.append("")
|
||||
out.append(
|
||||
"Auto-generated from the registered router by "
|
||||
"`docs/_generate_api_reference.py`. Endpoint names match the Python "
|
||||
"interface (`obb.technical.<name>` or `obb.technical.signals.<name>`) "
|
||||
"and the HTTP path (`POST /api/v1/technical/<name>`)."
|
||||
)
|
||||
out.append("")
|
||||
out.append(f"**Total endpoints**: {len(entries)}")
|
||||
out.append("")
|
||||
out.append(
|
||||
"Each endpoint also takes `data` (the OHLC(V) price series) which is "
|
||||
"omitted from the parameter tables below. `requires_columns` lists "
|
||||
"the OHLC(V) columns the endpoint reads from `data`."
|
||||
)
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for cat_key, cat_title, cat_blurb in CATEGORY_ORDER:
|
||||
cat_entries = by_cat.get(cat_key, [])
|
||||
if not cat_entries:
|
||||
continue
|
||||
out.append(f"## {cat_title}")
|
||||
out.append("")
|
||||
out.append(cat_blurb)
|
||||
out.append("")
|
||||
for entry in sorted(cat_entries, key=lambda e: e.name):
|
||||
out.append(f"### `{entry.name}`")
|
||||
out.append("")
|
||||
first_line = (entry.description or "").strip().split("\n", 1)[
|
||||
0
|
||||
] or "(no description)"
|
||||
out.append(f"_{first_line}_")
|
||||
out.append("")
|
||||
if entry.requires_columns:
|
||||
cols = ", ".join(f"`{c}`" for c in entry.requires_columns)
|
||||
out.append(f"**Required input columns**: {cols}")
|
||||
out.append("")
|
||||
|
||||
if entry.params:
|
||||
out.append("**Parameters** (in addition to `data`):")
|
||||
out.append("")
|
||||
out.append("| Name | Type | Default | Constraints | Description |")
|
||||
out.append("|---|---|---|---|---|")
|
||||
for p in entry.params:
|
||||
ptype = type_str(p.type)
|
||||
if p.choices:
|
||||
ptype = (
|
||||
"Literal[" + ", ".join(f'"{c}"' for c in p.choices) + "]"
|
||||
)
|
||||
default = (
|
||||
f"`{p.default}`" if p.default is not None else "*required*"
|
||||
)
|
||||
cons = constraints_str(p.constraints)
|
||||
out.append(
|
||||
f"| `{p.name}` | `{ptype}` | {default} | {cons} | "
|
||||
f"{cell(p.description)} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
if entry.output_columns:
|
||||
out.append(
|
||||
"**Returns** — `OBBject` with `results` list of rows containing:"
|
||||
)
|
||||
out.append("")
|
||||
out.append("| Column | Type | Nullable | Description |")
|
||||
out.append("|---|---|---|---|")
|
||||
for o in entry.output_columns:
|
||||
otype = type_str(o.type)
|
||||
nullable = "yes" if o.nullable else "no"
|
||||
out.append(
|
||||
f"| `{o.name}` | `{otype}` | {nullable} | {cell(o.description)} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
target = pathlib.Path(__file__).parent / "api-reference.md"
|
||||
target.write_text(render())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1447
openbb_platform/extensions/technical/docs/api-reference.md
vendored
Normal file
1447
openbb_platform/extensions/technical/docs/api-reference.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
90
openbb_platform/extensions/technical/docs/getting-started.md
vendored
Normal file
90
openbb_platform/extensions/technical/docs/getting-started.md
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
# Getting Started
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install openbb-technical
|
||||
```
|
||||
|
||||
The extension depends on `openbb-core[pandas]`, `pandas-ta-openbb`, `scikit-learn`, `scipy`, and `statsmodels` — all installed transitively.
|
||||
|
||||
## Three ways to call an endpoint
|
||||
|
||||
Every endpoint is exposed identically through the Python SDK, the REST API, and the CLI. Pick whichever fits your workflow.
|
||||
|
||||
### 1. Python
|
||||
|
||||
```python
|
||||
from openbb import obb
|
||||
|
||||
data = obb.equity.price.historical(
|
||||
symbol="SPY", start_date="2024-01-01", provider="yfinance"
|
||||
).results
|
||||
|
||||
result = obb.technical.rsi(data=data, length=14)
|
||||
print(result.results[0]) # first row of the RSI series
|
||||
df = result.to_df() # convert results to a DataFrame
|
||||
```
|
||||
|
||||
### 2. REST API
|
||||
|
||||
Start the API server (defaults to `http://localhost:8000`):
|
||||
|
||||
```bash
|
||||
openbb-api
|
||||
```
|
||||
|
||||
POST to the endpoint. The `data` array is the request body; scalar args are query-string parameters:
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8000/api/v1/technical/rsi?length=14' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '[{"date":"2024-01-02","open":471.5,"high":473.7,"low":471.0,"close":472.6,"volume":78900},
|
||||
{"date":"2024-01-03","open":472.5,"high":473.1,"low":468.7,"close":468.8,"volume":111200},
|
||||
...]'
|
||||
```
|
||||
|
||||
Endpoints that take more than one body argument (`screen`, `multi`, `correlation`, `relative_rotation`, `realized_volatility_compare`, `signals/candlestick_patterns`) wrap the body as `{"data": [...], "<other>": [...]}`.
|
||||
|
||||
### 3. CLI
|
||||
|
||||
```bash
|
||||
openbb -i
|
||||
> /technical/rsi --data <path_or_json> --length 14
|
||||
```
|
||||
|
||||
The CLI auto-generates an argparse interface from each endpoint's signature, so `--help` after any command lists every parameter with its type and default.
|
||||
|
||||
## Working with returns
|
||||
|
||||
Every endpoint returns an `OBBject`. The interesting bits:
|
||||
|
||||
```python
|
||||
result = obb.technical.bbands(data=data, length=20)
|
||||
|
||||
result.results # list[BbandsData] — one row per bar
|
||||
result.to_df() # pandas DataFrame keyed by date
|
||||
result.to_dict() # dict form
|
||||
len(result.results) # row count
|
||||
result.results[-1] # last row (most recent bar)
|
||||
```
|
||||
|
||||
## Discovering what's available
|
||||
|
||||
The `indicators` endpoint introspects the router and returns a structured catalog:
|
||||
|
||||
```python
|
||||
catalog = obb.technical.indicators(category="all").results
|
||||
for entry in catalog.indicators:
|
||||
print(entry.name, entry.category, [p.name for p in entry.params])
|
||||
```
|
||||
|
||||
Filter by category — `overlay`, `oscillator`, `volatility`, `volume`, `trend`, `signal`, `structure`, `stats`, `multi`, or `all`.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Single-symbol indicator workflows](./user-guides/01-indicators.md)
|
||||
- [Signal endpoints](./user-guides/02-signals.md)
|
||||
- [Multi-symbol analysis](./user-guides/03-multi-symbol.md)
|
||||
- [Multi-indicator composition + catalog](./user-guides/04-composition-and-discovery.md)
|
||||
- [Full API reference](./api-reference.md)
|
||||
139
openbb_platform/extensions/technical/docs/user-guides/01-indicators.md
vendored
Normal file
139
openbb_platform/extensions/technical/docs/user-guides/01-indicators.md
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# Single-Symbol Indicators
|
||||
|
||||
The bulk of the extension — 38 endpoints — computes a technical indicator on a single OHLC(V) series. They're grouped by family below. Every example assumes:
|
||||
|
||||
```python
|
||||
from openbb import obb
|
||||
|
||||
data = obb.equity.price.historical(
|
||||
symbol="SPY", start_date="2022-01-01", provider="yfinance"
|
||||
).results
|
||||
```
|
||||
|
||||
## Overlays (15 endpoints)
|
||||
|
||||
Smooth or envelope the price series. Output overlays on the same axis as price.
|
||||
|
||||
```python
|
||||
sma = obb.technical.sma(data=data, length=50).to_df()
|
||||
ema = obb.technical.ema(data=data, length=20).to_df()
|
||||
bb = obb.technical.bbands(data=data, length=20, std=2.0).to_df()
|
||||
ich = obb.technical.ichimoku(data=data).to_df()
|
||||
st = obb.technical.supertrend(data=data, length=10, multiplier=3.0).to_df()
|
||||
```
|
||||
|
||||
Moving-average variants: `sma`, `ema`, `hma` (Hull), `wma` (Weighted), `zlma` (Zero-Lag), `tema` (Triple-EMA), `dema` (Double-EMA), `kama` (Kaufman Adaptive), `frama` (Fractal Adaptive), `vwma` (Volume-Weighted).
|
||||
|
||||
Bands / channels: `bbands` (Bollinger), `donchian`, `kc` (Keltner).
|
||||
|
||||
Composites: `ichimoku` (5-line cloud), `supertrend` (ATR-anchored trailing band).
|
||||
|
||||
## Oscillators (10 endpoints)
|
||||
|
||||
Bounded momentum indicators. Often consulted alongside an overlay for divergence signals.
|
||||
|
||||
```python
|
||||
rsi = obb.technical.rsi(data=data, length=14).to_df()
|
||||
stoch = obb.technical.stoch(data=data).to_df() # %K and %D
|
||||
mfi = obb.technical.mfi(data=data, length=14).to_df() # volume-weighted RSI
|
||||
```
|
||||
|
||||
All 10: `rsi`, `stoch` (Stochastic), `cci` (Commodity Channel), `fisher` (Fisher Transform), `cg` (Centre of Gravity), `williams_r`, `mfi` (Money Flow Index), `trix` (Triple Smoothed ROC), `ultimate_oscillator`, `awesome_oscillator`.
|
||||
|
||||
## Trend (5 endpoints)
|
||||
|
||||
Decompose the price series into direction + strength signals.
|
||||
|
||||
```python
|
||||
macd = obb.technical.macd(data=data, fast=12, slow=26, signal=9).to_df()
|
||||
adx = obb.technical.adx(data=data, length=14).to_df()
|
||||
aroon = obb.technical.aroon(data=data, length=14).to_df()
|
||||
```
|
||||
|
||||
`macd` (Moving Average Convergence/Divergence), `adx` (Average Directional Index), `di` (Directional Indicator +DI/-DI), `aroon`, `choppiness`.
|
||||
|
||||
## Volume (4 endpoints)
|
||||
|
||||
Use the volume column. Useful for confirming or invalidating price moves.
|
||||
|
||||
```python
|
||||
obv = obb.technical.obv(data=data).to_df()
|
||||
ad = obb.technical.ad(data=data).to_df()
|
||||
vwap = obb.technical.vwap(data=data, anchor="D").to_df() # pandas offset alias
|
||||
```
|
||||
|
||||
`obv` (On-Balance Volume), `ad` (Accumulation/Distribution), `adosc` (Chaikin A/D Oscillator), `vwap` (Volume-Weighted Average Price).
|
||||
|
||||
## Volatility (4 endpoints)
|
||||
|
||||
```python
|
||||
atr = obb.technical.atr(data=data, length=14).to_df()
|
||||
|
||||
# All six realised-volatility estimators side by side
|
||||
rv = obb.technical.realized_volatility_compare(data=data, window=30).to_df()
|
||||
|
||||
# Volatility cones — historical band of rolling vol vs. realised today
|
||||
cones = obb.technical.cones(data=data, model="yang_zhang").to_df()
|
||||
```
|
||||
|
||||
`atr` (Average True Range), `realized_volatility` (single estimator), `realized_volatility_compare` (all six estimators in one frame), `cones` (per-window realised vs. historical band).
|
||||
|
||||
Estimators available for `realized_volatility` and `cones`: `std` (close-to-close), `parkinson` (range-based), `garman_klass` (OHLC), `hodges_tompkins` (bias-corrected close-to-close), `rogers_satchell` (drift-invariant range), `yang_zhang` (minimum-variance composite).
|
||||
|
||||
## Structure (3 endpoints)
|
||||
|
||||
Price-action levels (support/resistance/pivot points).
|
||||
|
||||
```python
|
||||
fib = obb.technical.fib(data=data, period=120).to_df()
|
||||
dem = obb.technical.demark(data=data).to_df()
|
||||
piv = obb.technical.pivot_points(data=data, method="classic", anchor="day").to_df()
|
||||
```
|
||||
|
||||
`fib` (Fibonacci retracement), `demark` (sequential 9 setup), `pivot_points` (five methods: classic, fibonacci, woodie, camarilla, demark; three anchors: day, week, month).
|
||||
|
||||
## Statistics (6 endpoints)
|
||||
|
||||
Distributional and time-series diagnostics on a price or return series.
|
||||
|
||||
```python
|
||||
clenow = obb.technical.clenow(data=data, period=90).to_df()
|
||||
dd = obb.technical.drawdown(data=data).to_df()
|
||||
stats = obb.technical.returns_stats(data=data, frequency="daily").to_df()
|
||||
stat = obb.technical.stationarity(data=data).to_df()
|
||||
hurst = obb.technical.hurst(data=data).to_df()
|
||||
acf = obb.technical.autocorrelation(data=data, max_lag=20).to_df()
|
||||
```
|
||||
|
||||
`clenow` (volatility-adjusted momentum), `drawdown` (peak-to-trough), `returns_stats` (skew, kurtosis, sharpe, sortino, etc.), `stationarity` (ADF + KPSS hypothesis tests), `hurst` (R/S and DFA exponents), `autocorrelation` (ACF + PACF with confidence bands).
|
||||
|
||||
## Common parameters
|
||||
|
||||
Every data-consuming endpoint accepts:
|
||||
|
||||
- `data` — the price series (`list[Data]` / DataFrame / records).
|
||||
- `index` — column to use as the time index (default `"date"`).
|
||||
- `target` — for endpoints that work on a single column, the column name (default `"close"`).
|
||||
|
||||
Overlay/oscillator endpoints expose a `length` (or `period`) parameter for the rolling window. Most also accept `offset` to shift the output. See the per-endpoint docstring (`help(obb.technical.<name>)`) for the full parameter list.
|
||||
|
||||
## Choosing an input shape
|
||||
|
||||
`pandas.DataFrame` is convenient if you're already in pandas:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
df = pd.read_csv("spy.csv")
|
||||
obb.technical.rsi(data=df, length=14)
|
||||
```
|
||||
|
||||
`list[dict]` is the simplest when reading from CSV or JSON without pandas:
|
||||
|
||||
```python
|
||||
import csv
|
||||
with open("spy.csv") as f:
|
||||
records = list(csv.DictReader(f))
|
||||
obb.technical.rsi(data=records, length=14)
|
||||
```
|
||||
|
||||
Both produce identical output.
|
||||
130
openbb_platform/extensions/technical/docs/user-guides/02-signals.md
vendored
Normal file
130
openbb_platform/extensions/technical/docs/user-guides/02-signals.md
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
# Signal Endpoints
|
||||
|
||||
The `/signals/` subrouter exposes six event-detection endpoints. Unlike the indicator endpoints (which return a dense per-bar series), these emit a **sparse list of detected events** — rows only on bars where the condition fires.
|
||||
|
||||
All examples assume:
|
||||
|
||||
```python
|
||||
from openbb import obb
|
||||
|
||||
data = obb.equity.price.historical(
|
||||
symbol="SPY", start_date="2022-01-01", provider="yfinance"
|
||||
).results
|
||||
```
|
||||
|
||||
## Crossovers
|
||||
|
||||
Detects moving-average crossover events between a fast and slow MA.
|
||||
|
||||
```python
|
||||
events = obb.technical.signals.crossovers(
|
||||
data=data,
|
||||
fast_length=20,
|
||||
slow_length=50,
|
||||
mamode="sma", # or "ema", "wma", "hma", "zlma"
|
||||
).results
|
||||
```
|
||||
|
||||
Each event row carries: `date`, `direction` (`"bullish"` / `"bearish"`), `fast_value`, `slow_value`, `price`, `distance` (price gap between the two MAs at the crossover bar).
|
||||
|
||||
## Oscillator Signals
|
||||
|
||||
Labels every bar with a regime (`overbought` / `oversold` / `neutral`) for the chosen oscillator and flags the four boundary-crossing events.
|
||||
|
||||
```python
|
||||
sig = obb.technical.signals.oscillator_signals(
|
||||
data=data,
|
||||
indicator="rsi", # or "mfi", "stoch", "williams_r", "cci"
|
||||
length=14,
|
||||
overbought_threshold=70.0, # optional override; defaults per indicator
|
||||
oversold_threshold=30.0,
|
||||
).results
|
||||
```
|
||||
|
||||
This is a **dense** output (one row per bar) with boolean flags `crossed_into_overbought`, `crossed_into_oversold`, `crossed_out_of_overbought`, `crossed_out_of_oversold` — convenient for plotting shaded regimes and vertical event markers.
|
||||
|
||||
## Breakouts
|
||||
|
||||
Detects when price closes beyond a rolling-channel envelope.
|
||||
|
||||
```python
|
||||
events = obb.technical.signals.breakouts(
|
||||
data=data,
|
||||
method="donchian", # rolling high/low; or "bollinger" (mean +/- stdev band)
|
||||
length=20,
|
||||
band_std=2.0, # only used for "bollinger"
|
||||
).results
|
||||
```
|
||||
|
||||
Each event row carries: `date`, `direction`, `price`, `band` (the breached level), `magnitude` (signed price - band), `bars_in_range` (consolidation length).
|
||||
|
||||
## Divergences
|
||||
|
||||
Detects classical bullish/bearish/hidden/exaggerated divergences between price and an oscillator across the last `lookback` bars.
|
||||
|
||||
```python
|
||||
events = obb.technical.signals.divergences(
|
||||
data=data,
|
||||
indicator="rsi", # or "macd", "stoch", "cci"
|
||||
indicator_length=14,
|
||||
lookback=60,
|
||||
swing_window=3, # bars on either side that define a swing high/low
|
||||
).results
|
||||
```
|
||||
|
||||
Each event row identifies the kind, the two swing dates that form the pattern, the indicator and price values at each swing, and the bar where the divergence was confirmed.
|
||||
|
||||
## Candlestick Patterns
|
||||
|
||||
Single-bar and two-bar pattern detection.
|
||||
|
||||
```python
|
||||
events = obb.technical.signals.candlestick_patterns(
|
||||
data=data,
|
||||
patterns=["doji", "hammer", "shooting_star", "engulfing"], # None = all
|
||||
).results
|
||||
```
|
||||
|
||||
Each event row carries: `date`, `pattern` name, `direction` (`bullish` / `bearish` / `neutral`), `confidence` (0-1, based on body/wick ratios meeting textbook thresholds).
|
||||
|
||||
Supported patterns: `doji`, `hammer`, `inverted_hammer`, `hanging_man`, `shooting_star`, `bullish_engulfing`, `bearish_engulfing`, `morning_star`, `evening_star`, `three_white_soldiers`, `three_black_crows`, `harami_bullish`, `harami_bearish`, `piercing`, `dark_cloud_cover`. Pass `patterns=None` to scan for all of them.
|
||||
|
||||
## Regime
|
||||
|
||||
Classifies each bar into one of four regimes based on ADX strength and price trendiness.
|
||||
|
||||
```python
|
||||
out = obb.technical.signals.regime(
|
||||
data=data,
|
||||
length=14,
|
||||
adx_strong_threshold=25.0,
|
||||
adx_weak_threshold=20.0,
|
||||
).results
|
||||
```
|
||||
|
||||
Regimes: `strong_trend`, `weak_trend`, `ranging`, `transition`. Each row also flags `regime_changed` on bars where the label shifts — useful for triggering position-sizing rules tied to regime persistence.
|
||||
|
||||
## Backtest pattern
|
||||
|
||||
The sparse event endpoints (`crossovers`, `breakouts`, `divergences`, `candlestick_patterns`) are designed to plug directly into a vectorised backtester:
|
||||
|
||||
```python
|
||||
events = obb.technical.signals.crossovers(data=data, fast_length=20, slow_length=50).results
|
||||
trades = [
|
||||
e for e in events
|
||||
if e.direction == "bullish" and e.distance > 0.5 # filter weak crossovers
|
||||
]
|
||||
```
|
||||
|
||||
The dense endpoints (`oscillator_signals`, `regime`) are designed for filter conditions:
|
||||
|
||||
```python
|
||||
sig = obb.technical.signals.oscillator_signals(data=data, indicator="rsi", length=14).results
|
||||
regime = obb.technical.signals.regime(data=data).results
|
||||
|
||||
# Only act on RSI crosses during a strong trend
|
||||
combined = [
|
||||
s for s, r in zip(sig, regime)
|
||||
if s.crossed_into_overbought and r.regime == "strong_trend"
|
||||
]
|
||||
```
|
||||
127
openbb_platform/extensions/technical/docs/user-guides/03-multi-symbol.md
vendored
Normal file
127
openbb_platform/extensions/technical/docs/user-guides/03-multi-symbol.md
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
# Multi-Symbol Analysis
|
||||
|
||||
Five endpoints take a long-format multi-symbol price series (one DataFrame with a `symbol` column) and produce cross-sectional or relative-strength output.
|
||||
|
||||
Example input shape:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv("dow30.csv") # columns: date, symbol, open, high, low, close, volume
|
||||
print(df.head())
|
||||
# date open high low close volume symbol
|
||||
# 0 2024-01-02 187.15 188.44 183.89 185.64 82488700 AAPL
|
||||
# 1 2024-01-02 376.04 376.04 370.66 370.87 25258600 MSFT
|
||||
# ...
|
||||
|
||||
data = df.to_dict(orient="records")
|
||||
```
|
||||
|
||||
## Correlation (paired)
|
||||
|
||||
Returns the rolling Pearson, Spearman, or Kendall correlation of returns for specific symbol pairs.
|
||||
|
||||
```python
|
||||
result = obb.technical.correlation(
|
||||
data=data,
|
||||
pairs=[("AAPL", "MSFT"), ("AAPL", "NVDA"), ("NVDA", "MSFT")],
|
||||
window=60,
|
||||
method="pearson",
|
||||
).to_df()
|
||||
```
|
||||
|
||||
Output is a long-format frame with one row per `(symbol_a, symbol_b, date)` tuple. With `window=None`, returns the single full-sample correlation per pair.
|
||||
|
||||
## Correlation Matrix (snapshot)
|
||||
|
||||
Builds a single point-in-time correlation matrix across every symbol in the input.
|
||||
|
||||
```python
|
||||
result = obb.technical.correlation_matrix(
|
||||
data=data,
|
||||
window=60, # rolling window ending at as_of_date
|
||||
method="spearman",
|
||||
as_of_date="2024-06-28", # None = most recent date in input
|
||||
).results[0]
|
||||
print(result.symbols) # ['AAPL', 'AMZN', 'MSFT', ...]
|
||||
print(result.matrix) # nested list, indexed by symbols order
|
||||
```
|
||||
|
||||
## Screen
|
||||
|
||||
Filters a universe by indicator-driven predicates. Each condition specifies an indicator endpoint, the column to read, an operator, and a threshold.
|
||||
|
||||
```python
|
||||
matches = obb.technical.screen(
|
||||
data=data,
|
||||
conditions=[
|
||||
{
|
||||
"indicator": "rsi",
|
||||
"column": "close_RSI_14",
|
||||
"operator": "lt",
|
||||
"value": 30.0,
|
||||
},
|
||||
{
|
||||
"indicator": "sma",
|
||||
"column": "close_SMA_50",
|
||||
"operator": "gt",
|
||||
"value": 0.0,
|
||||
},
|
||||
],
|
||||
combine="and", # "or" for union semantics
|
||||
as_of_date="2024-06-28", # None = each symbol's latest bar
|
||||
).results
|
||||
```
|
||||
|
||||
Supported operators:
|
||||
|
||||
| Operator | `value` shape | Fires when |
|
||||
|---|---|---|
|
||||
| `gt`, `gte`, `lt`, `lte`, `eq` | scalar | latest value matches |
|
||||
| `between` | `(low, high)` | `low <= latest <= high` |
|
||||
| `crossed_above`, `crossed_below` | `(threshold, lookback)` | series crosses the threshold within the trailing `lookback` bars |
|
||||
| `made_high`, `made_low` | `(0, lookback)` | latest is the max/min over the trailing `lookback` bars |
|
||||
|
||||
Each matched row carries the symbol, the as-of date used, how many conditions fired, and the indicator values at the trigger.
|
||||
|
||||
## Relative Rotation Graph
|
||||
|
||||
Computes the JdK RS-Ratio (relative-strength level) and RS-Momentum (relative-strength rate of change) for every constituent against a benchmark. Used to locate symbols in one of four rotational quadrants.
|
||||
|
||||
```python
|
||||
# The benchmark symbol must be present in `data`. The canonical RRG setup is
|
||||
# the benchmark (e.g. an index ETF) plus the constituents you want to rotate
|
||||
# against it. If you load Dow 30 OHLCV, append SPY rows to `data` before this
|
||||
# call, or pick one of the constituents as the benchmark.
|
||||
rrg = obb.technical.relative_rotation(
|
||||
data=data,
|
||||
benchmark="AAPL", # any symbol present in `data`
|
||||
study="price", # or "volume", "volatility"
|
||||
long_period=252, # RS-Ratio standardisation window
|
||||
short_period=21, # RS-Momentum standardisation window
|
||||
window=21, # trail length retained in the output
|
||||
trading_periods=252, # used by the volatility study
|
||||
)
|
||||
|
||||
rrg.results.rs_ratios # one frame per symbol, indexed by date
|
||||
rrg.results.rs_momentum # same shape
|
||||
rrg.results.benchmark # echoed for charting
|
||||
```
|
||||
|
||||
Quadrant interpretation (`x = RS-Ratio`, `y = RS-Momentum`, both centred at 100):
|
||||
|
||||
| RS-Ratio | RS-Momentum | Quadrant | Interpretation |
|
||||
|---|---|---|---|
|
||||
| > 100 | > 100 | Leading | Outperforming and accelerating |
|
||||
| > 100 | < 100 | Weakening | Still outperforming but momentum is fading |
|
||||
| < 100 | < 100 | Lagging | Underperforming with negative momentum |
|
||||
| < 100 | > 100 | Improving | Underperforming but momentum is turning up |
|
||||
|
||||
Symbols rotate clockwise through the quadrants over time. The `window=21` trail draws the rotation arc on a chart.
|
||||
|
||||
## Input format constraints
|
||||
|
||||
- **`symbol` column required.** All multi-symbol endpoints group by it.
|
||||
- **Long format only.** Each row is one (symbol, date) observation. Don't pre-pivot.
|
||||
- **Daily data is the default assumption for `relative_rotation`.** It requires at least `long_period + window` bars per symbol (default ~273 daily bars). The volatility study needs roughly twice as much.
|
||||
- **Identical date grids preferred.** Symbols whose date series don't fully overlap will be intersected pairwise; the correlation/RRG endpoints work on the intersection.
|
||||
96
openbb_platform/extensions/technical/docs/user-guides/04-composition-and-discovery.md
vendored
Normal file
96
openbb_platform/extensions/technical/docs/user-guides/04-composition-and-discovery.md
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
# Composition and Discovery
|
||||
|
||||
Two utility endpoints round out the extension: `multi` runs many indicators on the same series in one call, and `indicators` introspects the registered router to return a machine-readable catalog.
|
||||
|
||||
## multi — many indicators in one call
|
||||
|
||||
`multi` dispatches a list of indicator requests against a single input series and merges every numeric output column onto a common date index. Useful for building a feature frame in one round trip.
|
||||
|
||||
```python
|
||||
from openbb import obb
|
||||
|
||||
data = obb.equity.price.historical(symbol="SPY", start_date="2023-01-01").results
|
||||
|
||||
result = obb.technical.multi(
|
||||
data=data,
|
||||
indicators=[
|
||||
{"indicator": "rsi", "params": {"length": 14}},
|
||||
{"indicator": "macd", "params": {"fast": 12, "slow": 26, "signal": 9}},
|
||||
{"indicator": "bbands", "params": {"length": 20, "std": 2.0}},
|
||||
{"indicator": "atr", "params": {"length": 14}},
|
||||
],
|
||||
).to_df()
|
||||
|
||||
print(result.head())
|
||||
# rsi.close_RSI_14 macd.MACD_12_26_9 macd.MACDh_12_26_9 bbands.BBL_20_2.0 ...
|
||||
# date
|
||||
# 2023-01-23 46.8 -2.13 0.41 388.74 ...
|
||||
```
|
||||
|
||||
Output column naming is `"<indicator>.<column>"` so you can tell which endpoint produced which value.
|
||||
|
||||
Unknown indicator names are silently skipped (so a heterogeneous configuration tolerated). The valid names match the registered router endpoints; see `indicators` (below) for the canonical list.
|
||||
|
||||
### When to use multi vs. calling endpoints directly
|
||||
|
||||
- **Use `multi`** when you want a feature frame for downstream modelling — you avoid uploading the same OHLCV payload N times and you get one aligned DataFrame.
|
||||
- **Call endpoints directly** when you care about the per-endpoint output shape (e.g. `bbands` returns three columns plus a bandwidth; the typed `BbandsData` row preserves that structure where `multi` collapses to a flat dict).
|
||||
|
||||
## indicators — programmatic catalog
|
||||
|
||||
The `indicators` endpoint returns a structured description of every registered router command — name, category, parameters with types and defaults, output columns, and the docstring summary line.
|
||||
|
||||
```python
|
||||
catalog = obb.technical.indicators(category="all").results
|
||||
|
||||
print(len(catalog.indicators)) # total endpoints registered
|
||||
for entry in catalog.indicators:
|
||||
summary = entry.description.split("\n", 1)[0] # first line of the docstring
|
||||
print(f"{entry.category:>10s} {entry.name:<20s} {summary}")
|
||||
```
|
||||
|
||||
Filter by category to narrow the listing:
|
||||
|
||||
```python
|
||||
overlays = obb.technical.indicators(category="overlay").results
|
||||
oscillators = obb.technical.indicators(category="oscillator").results
|
||||
signals = obb.technical.indicators(category="signal").results
|
||||
```
|
||||
|
||||
Categories: `overlay`, `oscillator`, `volatility`, `volume`, `trend`, `signal`, `structure`, `stats`, `multi`, `all`.
|
||||
|
||||
### Per-entry shape
|
||||
|
||||
Each `IndicatorEntry` carries:
|
||||
|
||||
```python
|
||||
entry = obb.technical.indicators(category="all").results.indicators[0]
|
||||
|
||||
entry.name # str — endpoint name as called via obb.technical.<name>
|
||||
entry.category # str — one of the categories above
|
||||
entry.description # str — full QueryParams class docstring (first line is the summary)
|
||||
entry.requires_columns # list[str] — OHLC(V) columns the endpoint reads from `data`
|
||||
entry.params # list[IndicatorParam] — every kwarg besides `data`
|
||||
entry.output_columns # list[IndicatorOutputColumn] — every output column
|
||||
entry.example_call # dict — a ready-to-invoke kwargs example with default values
|
||||
```
|
||||
|
||||
`IndicatorParam` rows include `name`, `type` (the Python type repr), `default` (or `None` if required), `choices` (the literal values for `Literal[...]` typed fields, otherwise `None`), `description`, and a `constraints` dict carrying `gt`/`ge`/`lt`/`le` bounds where the field declares them.
|
||||
|
||||
`IndicatorOutputColumn` rows include `name` (the dict key in the output row), `type`, `nullable`, and `description`.
|
||||
|
||||
### Use cases
|
||||
|
||||
**Build a UI picker.** Render `entry.params` as form fields with the correct widget per type, with `choices` driving dropdowns and `constraints` (`ge`/`le`/`gt`/`lt`) driving spinners.
|
||||
|
||||
**Generate an OpenAPI client.** The catalog mirrors what the OpenAPI schema exposes, but at a higher abstraction level (no FastAPI body-wrapping confusion).
|
||||
|
||||
**Drive an LLM tool-call.** Pass the catalog as tool descriptions to a model; it can pick the right indicator and produce the correct `params` dict.
|
||||
|
||||
**Validate user input client-side.** Use `parameters[*].choices` and bounds to validate before hitting the server.
|
||||
|
||||
## Adding new indicators is automatic
|
||||
|
||||
The catalog introspects the registered router at request time, so any endpoint added to `openbb_technical/indicators/`, `openbb_technical/signals/`, or `openbb_technical/multi/` shows up automatically — no separate registry to maintain.
|
||||
|
||||
The `__category__` and `__output_columns__` class attributes on each `XxxQueryParams` class drive the category bucket and output-column documentation. Set them on any new endpoint to participate in the catalog.
|
||||
149
openbb_platform/extensions/technical/integration/conftest.py
Normal file
149
openbb_platform/extensions/technical/integration/conftest.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Shared fixtures for the integration test suite.
|
||||
|
||||
The fixtures here load deterministic OHLC(V) data from CSV files staged in
|
||||
``integration/fixtures/``. Records are capped at a small per-interval limit so
|
||||
the Pydantic validation cost inside the static-package container stays cheap.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
# Capping rows keeps Pydantic validation fast. 400 daily bars is enough to
|
||||
# warm every rolling indicator up to length 252 (relative_rotation default).
|
||||
_ROW_CAP = 400
|
||||
|
||||
|
||||
def _read_csv_records(name: str, cap: int | None = _ROW_CAP) -> list[dict]:
|
||||
"""Read a fixture CSV and return long-format list[dict] records, capped."""
|
||||
df = pd.read_csv(FIXTURES_DIR / name)
|
||||
if cap is not None:
|
||||
df = df.tail(cap)
|
||||
return df.to_dict(orient="records")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_1d() -> list[dict]:
|
||||
"""SPY daily bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_1d.csv")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_60m() -> list[dict]:
|
||||
"""SPY 60-minute bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_60m.csv")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_30m() -> list[dict]:
|
||||
"""SPY 30-minute bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_30m.csv")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_15m() -> list[dict]:
|
||||
"""SPY 15-minute bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_15m.csv")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_5m() -> list[dict]:
|
||||
"""SPY 5-minute bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_5m.csv")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def spy_1m() -> list[dict]:
|
||||
"""SPY 1-minute bars (most recent 400)."""
|
||||
return _read_csv_records("SPY_1m.csv")
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="session",
|
||||
params=["spy_1d", "spy_30m"],
|
||||
ids=["1d", "30m"],
|
||||
)
|
||||
def spy_daily_or_intraday(request) -> list[dict]:
|
||||
"""Single-symbol fixture parametrised across one daily + one intraday set.
|
||||
|
||||
Two intervals are enough to exercise both the pure-date and the datetime
|
||||
code paths without paying for the full 6-interval matrix on every endpoint.
|
||||
"""
|
||||
return request.getfixturevalue(request.param)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dow30_multi() -> list[dict]:
|
||||
"""Daily OHLCV basket for a handful of Dow 30 names (capped for speed)."""
|
||||
# Trim to a fixed symbol subset and most-recent 400 rows per symbol to keep
|
||||
# multi-symbol endpoints (correlation, screen, relative_rotation) fast.
|
||||
df = pd.read_csv(FIXTURES_DIR / "multi.csv")
|
||||
keep = ["AAPL", "MSFT", "NVDA", "AMZN", "JPM", "V"]
|
||||
df = df[df["symbol"].isin(keep)]
|
||||
df = (
|
||||
df.sort_values(["symbol", "date"]).groupby("symbol", group_keys=False).tail(400)
|
||||
)
|
||||
return df.to_dict(orient="records")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def obb():
|
||||
"""Lazy ``openbb.obb`` accessor."""
|
||||
import openbb
|
||||
|
||||
return openbb.obb
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_server():
|
||||
"""Run the OpenBB REST API in a background thread; yield its base URL.
|
||||
|
||||
Starts uvicorn against ``openbb_core.api.rest_api:app`` on a free local
|
||||
port, polls the readiness endpoint until it answers, yields
|
||||
``http://127.0.0.1:<port>/api/v1``, and shuts the server down at session
|
||||
teardown. ``OPENBB_API_AUTH`` is left at its default (``False``) so tests
|
||||
can hit endpoints without basic-auth headers.
|
||||
"""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
import uvicorn
|
||||
from openbb_core.api.rest_api import app
|
||||
|
||||
# Bind to an ephemeral port so concurrent test runs do not collide.
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
config = uvicorn.Config(
|
||||
app, host="127.0.0.1", port=port, log_level="error", access_log=False
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}/api/v1"
|
||||
# Wait for the server to start serving requests; openapi.json is cheap.
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = requests.get(f"http://127.0.0.1:{port}/openapi.json", timeout=2.0)
|
||||
if r.status_code == 200:
|
||||
break
|
||||
except requests.RequestException:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
raise RuntimeError(f"uvicorn did not start on port {port} within 30s")
|
||||
|
||||
yield base_url
|
||||
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
1041
openbb_platform/extensions/technical/integration/fixtures/SPY_15m.csv
vendored
Normal file
1041
openbb_platform/extensions/technical/integration/fixtures/SPY_15m.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8348
openbb_platform/extensions/technical/integration/fixtures/SPY_1d.csv
vendored
Normal file
8348
openbb_platform/extensions/technical/integration/fixtures/SPY_1d.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1949
openbb_platform/extensions/technical/integration/fixtures/SPY_1m.csv
vendored
Normal file
1949
openbb_platform/extensions/technical/integration/fixtures/SPY_1m.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
521
openbb_platform/extensions/technical/integration/fixtures/SPY_30m.csv
vendored
Normal file
521
openbb_platform/extensions/technical/integration/fixtures/SPY_30m.csv
vendored
Normal file
@@ -0,0 +1,521 @@
|
||||
date,open,high,low,close,volume
|
||||
2026-01-30 09:30:00,691.7899780273438,693.7100219726562,691.2219848632812,691.6900024414062,10220277
|
||||
2026-01-30 10:00:00,691.6699829101562,694.0499877929688,691.239990234375,691.385009765625,5209301
|
||||
2026-01-30 10:30:00,691.3800048828125,694.2100219726562,690.8900146484375,692.1199951171875,4712297
|
||||
2026-01-30 11:00:00,692.1201171875,692.6900024414062,690.2550048828125,691.0599975585938,4208671
|
||||
2026-01-30 11:30:00,691.0499877929688,692.4299926757812,690.3800048828125,690.6599731445312,3410244
|
||||
2026-01-30 12:00:00,690.6699829101562,691.1900024414062,688.72998046875,691.0700073242188,5237055
|
||||
2026-01-30 12:30:00,691.0650024414062,691.530029296875,689.1300048828125,689.1699829101562,3644964
|
||||
2026-01-30 13:00:00,689.1500244140625,690.280029296875,687.1199951171875,689.469970703125,4445914
|
||||
2026-01-30 13:30:00,689.469970703125,692.0800170898438,687.8499755859375,691.3800048828125,4559168
|
||||
2026-01-30 14:00:00,691.3699951171875,692.7000122070312,691.2999877929688,691.3400268554688,5326403
|
||||
2026-01-30 14:30:00,691.3200073242188,692.280029296875,690.44580078125,690.5700073242188,6437381
|
||||
2026-01-30 15:00:00,690.5750122070312,691.2100219726562,689.2999877929688,690.0399780273438,6534278
|
||||
2026-01-30 15:30:00,690.0700073242188,692.826171875,689.969970703125,691.9000244140625,20608314
|
||||
2026-02-02 09:30:00,689.5800170898438,693.8400268554688,689.4299926757812,692.7000122070312,13387371
|
||||
2026-02-02 10:00:00,692.6199951171875,695.6699829101562,692.3099975585938,694.6849975585938,8585111
|
||||
2026-02-02 10:30:00,694.6849975585938,695.75,694.35498046875,694.72998046875,5022674
|
||||
2026-02-02 11:00:00,694.719970703125,695.6799926757812,693.9199829101562,695.5499877929688,4223929
|
||||
2026-02-02 11:30:00,695.5599975585938,695.8599853515625,694.489990234375,695.75,2876397
|
||||
2026-02-02 12:00:00,695.75,696.52001953125,695.6400146484375,696.2249755859375,3293193
|
||||
2026-02-02 12:30:00,696.2274780273438,696.9299926757812,696.0499877929688,696.405029296875,2164563
|
||||
2026-02-02 13:00:00,696.4099731445312,696.7199096679688,695.6849975585938,696.0601196289062,2519816
|
||||
2026-02-02 13:30:00,696.0700073242188,696.760009765625,696.0250244140625,696.489990234375,2092132
|
||||
2026-02-02 14:00:00,696.489990234375,696.8800048828125,696.3599853515625,696.7899780273438,2886700
|
||||
2026-02-02 14:30:00,696.7849731445312,696.7899780273438,695.344970703125,695.52001953125,4456759
|
||||
2026-02-02 15:00:00,695.52001953125,696.469970703125,695.0103149414062,695.80859375,7707301
|
||||
2026-02-02 15:30:00,695.8099975585938,695.9600219726562,695.0800170898438,695.4600219726562,13121586
|
||||
2026-02-03 09:30:00,696.6412963867188,696.9600219726562,693.3400268554688,693.47998046875,12531281
|
||||
2026-02-03 10:00:00,693.510009765625,693.719970703125,690.7999877929688,692.2050170898438,10174963
|
||||
2026-02-03 10:30:00,692.1900024414062,693.4299926757812,690.9299926757812,693.1400146484375,6765600
|
||||
2026-02-03 11:00:00,693.1400146484375,693.1900024414062,690.6699829101562,690.7999877929688,4239374
|
||||
2026-02-03 11:30:00,690.8099975585938,691.4498901367188,690.27001953125,690.6400146484375,4845241
|
||||
2026-02-03 12:00:00,690.6400146484375,690.739990234375,688.6099853515625,688.8300170898438,7180624
|
||||
2026-02-03 12:30:00,688.8200073242188,688.9400024414062,687.3900146484375,687.7000122070312,5911976
|
||||
2026-02-03 13:00:00,687.6900024414062,688.5499877929688,686.239990234375,686.3599853515625,6658261
|
||||
2026-02-03 13:30:00,686.364990234375,686.4099731445312,684.8499755859375,684.8599853515625,4446502
|
||||
2026-02-03 14:00:00,684.8400268554688,687.219970703125,684.030029296875,686.2135009765625,9707050
|
||||
2026-02-03 14:30:00,686.2100219726562,687.0800170898438,685.1300048828125,686.8300170898438,6391650
|
||||
2026-02-03 15:00:00,686.8200073242188,687.8049926757812,686.280029296875,687.530029296875,6115525
|
||||
2026-02-03 15:30:00,687.530029296875,689.75,687.489990234375,689.4600219726562,12789390
|
||||
2026-02-04 09:30:00,690.3499755859375,691.4500122070312,687.8300170898438,688.1900024414062,11617917
|
||||
2026-02-04 10:00:00,688.1699829101562,690.8499755859375,686.510009765625,689.9000244140625,9058586
|
||||
2026-02-04 10:30:00,689.9000244140625,690.27001953125,687.3200073242188,688.5800170898438,8084299
|
||||
2026-02-04 11:00:00,688.5900268554688,688.9000244140625,686.3900146484375,688.3499145507812,7244032
|
||||
2026-02-04 11:30:00,688.3499755859375,688.47998046875,684.3350219726562,686.5900268554688,7153342
|
||||
2026-02-04 12:00:00,686.5800170898438,687.0999755859375,683.219970703125,683.7000122070312,5057756
|
||||
2026-02-04 12:30:00,683.7000122070312,684.7999877929688,681.760009765625,683.6599731445312,9070977
|
||||
2026-02-04 13:00:00,683.6599731445312,685.010009765625,682.3300170898438,684.7999877929688,5538423
|
||||
2026-02-04 13:30:00,684.8099975585938,685.510009765625,683.1099853515625,684.6300048828125,5337075
|
||||
2026-02-04 14:00:00,684.6199951171875,685.405029296875,683.4000244140625,685.3400268554688,5111337
|
||||
2026-02-04 14:30:00,685.3400268554688,688.47998046875,685.2999877929688,688.27001953125,7759218
|
||||
2026-02-04 15:00:00,688.27001953125,688.489990234375,686.7449951171875,687.5399780273438,5601565
|
||||
2026-02-04 15:30:00,687.530029296875,688.3200073242188,685.6409912109375,686.1199951171875,10951574
|
||||
2026-02-05 09:30:00,680.9400024414062,683.6900024414062,678.760009765625,680.6099853515625,15721856
|
||||
2026-02-05 10:00:00,680.6099853515625,681.3800048828125,676.1749877929688,676.6400146484375,11679234
|
||||
2026-02-05 10:30:00,676.6500244140625,678.489990234375,675.9400024414062,677.3300170898438,9818293
|
||||
2026-02-05 11:00:00,677.3300170898438,679.7899780273438,675.7899780273438,679.6599731445312,7852513
|
||||
2026-02-05 11:30:00,679.6599731445312,681.5349731445312,678.9500122070312,681.239990234375,7074267
|
||||
2026-02-05 12:00:00,681.219970703125,681.9400024414062,678.97998046875,681.5,4825813
|
||||
2026-02-05 12:30:00,681.52001953125,682.614990234375,680.1699829101562,680.4500122070312,3960858
|
||||
2026-02-05 13:00:00,680.4600219726562,681.3200073242188,678.8499755859375,679.1500244140625,3837909
|
||||
2026-02-05 13:30:00,679.1599731445312,680.010009765625,677.6849975585938,678.30029296875,3852075
|
||||
2026-02-05 14:00:00,678.3099975585938,681.469970703125,678.030029296875,680.2449951171875,6518488
|
||||
2026-02-05 14:30:00,680.239990234375,680.239990234375,678.3900146484375,679.0900268554688,4632347
|
||||
2026-02-05 15:00:00,679.0900268554688,680.89501953125,677.6400146484375,677.77001953125,6639130
|
||||
2026-02-05 15:30:00,677.7899780273438,678.3499145507812,676.1854858398438,677.4400024414062,14065824
|
||||
2026-02-06 09:30:00,681.4600219726562,686.510009765625,680.8499755859375,684.7449951171875,17564509
|
||||
2026-02-06 10:00:00,684.7999877929688,685.8099975585938,683.1099853515625,685.6500244140625,7885250
|
||||
2026-02-06 10:30:00,685.6500244140625,687.0399780273438,684.3300170898438,685.7999877929688,7352844
|
||||
2026-02-06 11:00:00,685.7899780273438,687.719970703125,685.4299926757812,687.10498046875,6075477
|
||||
2026-02-06 11:30:00,687.10498046875,688.010009765625,686.9400024414062,687.4600219726562,4187890
|
||||
2026-02-06 12:00:00,687.4550170898438,688.510009765625,687.1400146484375,688.47998046875,3302822
|
||||
2026-02-06 12:30:00,688.5,689.5399780273438,687.8524169921875,688.9500122070312,3849214
|
||||
2026-02-06 13:00:00,688.9600219726562,689.510009765625,688.5900268554688,688.9450073242188,3014684
|
||||
2026-02-06 13:30:00,688.9500122070312,689.6599731445312,688.8800048828125,689.030029296875,2553507
|
||||
2026-02-06 14:00:00,689.030029296875,689.530029296875,688.739990234375,689.47802734375,3194613
|
||||
2026-02-06 14:30:00,689.47998046875,689.760009765625,688.9000244140625,689.3499755859375,3096524
|
||||
2026-02-06 15:00:00,689.3400268554688,690.1400146484375,689.0999755859375,689.7750244140625,4617208
|
||||
2026-02-06 15:30:00,689.77001953125,692.3099975585938,689.5999755859375,690.5999755859375,14828433
|
||||
2026-02-09 09:30:00,689.4199829101562,691.635009765625,688.3400268554688,690.4099731445312,12225578
|
||||
2026-02-09 10:00:00,690.4000244140625,692.9299926757812,690.219970703125,691.9400024414062,5824477
|
||||
2026-02-09 10:30:00,691.9600219726562,694.4600219726562,691.938720703125,694.239990234375,5639155
|
||||
2026-02-09 11:00:00,694.22998046875,694.760009765625,694.0599975585938,694.689697265625,4640584
|
||||
2026-02-09 11:30:00,694.6799926757812,695.5900268554688,694.27001953125,695.3900146484375,3716499
|
||||
2026-02-09 12:00:00,695.385009765625,695.6599731445312,695.1199951171875,695.1400146484375,2636026
|
||||
2026-02-09 12:30:00,695.1500244140625,695.510009765625,694.47998046875,694.5800170898438,2156392
|
||||
2026-02-09 13:00:00,694.5700073242188,695.0,693.5499877929688,694.22998046875,3609265
|
||||
2026-02-09 13:30:00,694.22998046875,695.1900024414062,694.0,694.6699829101562,2534025
|
||||
2026-02-09 14:00:00,694.6699829101562,695.8699951171875,694.489990234375,695.739990234375,3270140
|
||||
2026-02-09 14:30:00,695.739990234375,695.8499755859375,695.3400268554688,695.4199829101562,2466299
|
||||
2026-02-09 15:00:00,695.4199829101562,695.719970703125,694.3300170898438,694.5750122070312,4605472
|
||||
2026-02-09 15:30:00,694.5700073242188,695.02001953125,693.8800048828125,693.9099731445312,11568140
|
||||
2026-02-10 09:30:00,694.9500122070312,696.1599731445312,693.4550170898438,695.75,10045799
|
||||
2026-02-10 10:00:00,695.739990234375,696.4349975585938,694.7100219726562,695.9199829101562,5005896
|
||||
2026-02-10 10:30:00,695.905029296875,696.5399780273438,694.9099731445312,695.39501953125,3452116
|
||||
2026-02-10 11:00:00,695.4000244140625,695.6699829101562,694.0900268554688,694.780029296875,3801252
|
||||
2026-02-10 11:30:00,694.77001953125,695.4000244140625,694.3200073242188,694.77001953125,2419112
|
||||
2026-02-10 12:00:00,694.7550048828125,695.6701049804688,694.2000122070312,695.25,3299994
|
||||
2026-02-10 12:30:00,695.25,695.7999877929688,694.8250122070312,695.4412841796875,1686491
|
||||
2026-02-10 13:00:00,695.4500122070312,695.469970703125,693.3699951171875,693.4400024414062,3267938
|
||||
2026-02-10 13:30:00,693.4299926757812,693.8599853515625,692.8499755859375,693.6400146484375,4082507
|
||||
2026-02-10 14:00:00,693.6500244140625,693.905029296875,693.1300048828125,693.8699951171875,3008775
|
||||
2026-02-10 14:30:00,693.8699951171875,694.1300048828125,693.3599853515625,693.77001953125,2292104
|
||||
2026-02-10 15:00:00,693.760009765625,693.9929809570312,693.1099853515625,693.2974853515625,3190462
|
||||
2026-02-10 15:30:00,693.2999877929688,693.5399780273438,691.6599731445312,692.1099853515625,13770742
|
||||
2026-02-11 09:30:00,696.3900146484375,697.1300048828125,693.8049926757812,694.1199951171875,10890040
|
||||
2026-02-11 10:00:00,694.1300048828125,694.760009765625,689.1799926757812,689.719970703125,10524651
|
||||
2026-02-11 10:30:00,689.719970703125,692.6199951171875,689.5599975585938,692.3499755859375,7335912
|
||||
2026-02-11 11:00:00,692.3300170898438,693.4500122070312,691.3800048828125,691.8400268554688,4175798
|
||||
2026-02-11 11:30:00,691.8200073242188,693.3499755859375,691.5,692.0700073242188,3807619
|
||||
2026-02-11 12:00:00,692.0499877929688,693.8400268554688,691.3900146484375,693.5969848632812,4171905
|
||||
2026-02-11 12:30:00,693.5999755859375,693.989990234375,692.5499877929688,692.77001953125,2481578
|
||||
2026-02-11 13:00:00,692.77001953125,692.97998046875,691.4099731445312,692.7899780273438,3974008
|
||||
2026-02-11 13:30:00,692.7896728515625,693.02001953125,692.030029296875,692.72998046875,2966854
|
||||
2026-02-11 14:00:00,692.72998046875,693.9550170898438,692.1099853515625,693.739990234375,3858562
|
||||
2026-02-11 14:30:00,693.75,693.8599853515625,692.4199829101562,692.9400024414062,4196520
|
||||
2026-02-11 15:00:00,692.9199829101562,693.0999755859375,692.22998046875,692.2550048828125,4124062
|
||||
2026-02-11 15:30:00,692.25,692.989990234375,691.77001953125,691.9199829101562,9384677
|
||||
2026-02-12 09:30:00,694.239990234375,695.3499755859375,692.3200073242188,692.8300170898438,10208980
|
||||
2026-02-12 10:00:00,692.8300170898438,693.72998046875,692.1599731445312,693.0650024414062,5436749
|
||||
2026-02-12 10:30:00,693.0800170898438,693.47998046875,688.5850219726562,688.6500244140625,7705275
|
||||
2026-02-12 11:00:00,688.6500244140625,688.8049926757812,683.5750122070312,683.5999755859375,15158331
|
||||
2026-02-12 11:30:00,683.594970703125,685.8400268554688,683.5250244140625,684.6400146484375,9915277
|
||||
2026-02-12 12:00:00,684.6300048828125,685.27001953125,683.3800048828125,684.1199951171875,7043293
|
||||
2026-02-12 12:30:00,684.0999755859375,685.5750122070312,682.9500122070312,682.989990234375,4902663
|
||||
2026-02-12 13:00:00,682.989990234375,684.1199951171875,682.219970703125,683.1699829101562,5523603
|
||||
2026-02-12 13:30:00,683.1649780273438,685.3250122070312,682.7999877929688,684.7100219726562,5538416
|
||||
2026-02-12 14:00:00,684.7000122070312,685.4199829101562,683.594970703125,684.1500244140625,5166108
|
||||
2026-02-12 14:30:00,684.1300048828125,685.9650268554688,684.1199951171875,685.1599731445312,5560468
|
||||
2026-02-12 15:00:00,685.1799926757812,685.5399780273438,683.1099853515625,683.219970703125,6211522
|
||||
2026-02-12 15:30:00,683.2000122070312,683.4000244140625,680.3699951171875,681.219970703125,19241913
|
||||
2026-02-13 09:30:00,681.6900024414062,683.3200073242188,677.52001953125,679.9299926757812,17481343
|
||||
2026-02-13 10:00:00,679.9099731445312,683.3699951171875,679.7767944335938,682.9400024414062,7287512
|
||||
2026-02-13 10:30:00,682.9400024414062,684.5700073242188,680.7899780273438,682.5202026367188,7278187
|
||||
2026-02-13 11:00:00,682.52001953125,684.219970703125,681.8300170898438,683.8200073242188,4957855
|
||||
2026-02-13 11:30:00,683.8099975585938,685.510009765625,682.8699951171875,684.75,4310262
|
||||
2026-02-13 12:00:00,684.75,686.0499877929688,684.47998046875,685.364990234375,3989696
|
||||
2026-02-13 12:30:00,685.3800048828125,686.280029296875,684.8599853515625,685.0999755859375,3513166
|
||||
2026-02-13 13:00:00,685.0999755859375,686.260009765625,684.0900268554688,685.3900146484375,4433585
|
||||
2026-02-13 13:30:00,685.3900146484375,686.0599975585938,684.3699951171875,684.6599731445312,3433240
|
||||
2026-02-13 14:00:00,684.6699829101562,685.364990234375,684.0999755859375,684.3400268554688,3580111
|
||||
2026-02-13 14:30:00,684.3499755859375,684.9299926757812,682.239990234375,682.4000244140625,4271047
|
||||
2026-02-13 15:00:00,682.4000244140625,683.260009765625,680.3400268554688,680.6300048828125,6092813
|
||||
2026-02-13 15:30:00,680.6400146484375,681.8400268554688,679.8099975585938,681.5999755859375,13671717
|
||||
2026-02-17 09:30:00,680.1400146484375,682.7999877929688,676.4600219726562,676.6300048828125,13581128
|
||||
2026-02-17 10:00:00,676.6199951171875,679.75,676.27001953125,677.3400268554688,7327743
|
||||
2026-02-17 10:30:00,677.3300170898438,678.6400146484375,675.780029296875,678.6099853515625,6383432
|
||||
2026-02-17 11:00:00,678.5999755859375,683.239990234375,678.4500122070312,681.5999755859375,8896099
|
||||
2026-02-17 11:30:00,681.5750122070312,683.030029296875,680.8099975585938,682.4600219726562,4749903
|
||||
2026-02-17 12:00:00,682.4199829101562,682.5399780273438,680.4312744140625,682.2000122070312,3474041
|
||||
2026-02-17 12:30:00,682.2100219726562,682.3200073242188,679.1500244140625,681.0103149414062,3953490
|
||||
2026-02-17 13:00:00,681.02001953125,682.5399780273438,679.8300170898438,682.3900146484375,3099031
|
||||
2026-02-17 13:30:00,682.4000244140625,684.7150268554688,682.030029296875,684.0750122070312,4185741
|
||||
2026-02-17 14:00:00,684.0800170898438,684.6199951171875,683.5800170898438,683.989990234375,3248663
|
||||
2026-02-17 14:30:00,683.969970703125,684.9400024414062,683.4099731445312,683.5700073242188,2905047
|
||||
2026-02-17 15:00:00,683.5700073242188,684.0999755859375,682.6699829101562,682.8699951171875,4068800
|
||||
2026-02-17 15:30:00,682.8800048828125,683.2899780273438,681.9299926757812,682.75,9895361
|
||||
2026-02-18 09:30:00,684.02001953125,686.3800048828125,682.8300170898438,686.1599731445312,10377461
|
||||
2026-02-18 10:00:00,686.1699829101562,688.4650268554688,686.0850219726562,687.8400268554688,8075844
|
||||
2026-02-18 10:30:00,687.8400268554688,688.7249755859375,687.2899780273438,688.5150146484375,3499843
|
||||
2026-02-18 11:00:00,688.52001953125,688.9600219726562,688.02001953125,688.219970703125,3192040
|
||||
2026-02-18 11:30:00,688.219970703125,688.760009765625,687.4998779296875,687.530029296875,2432679
|
||||
2026-02-18 12:00:00,687.530029296875,688.8900146484375,687.3800048828125,688.2000122070312,2967596
|
||||
2026-02-18 12:30:00,688.1900024414062,688.7999877929688,687.8900146484375,688.5009765625,1985899
|
||||
2026-02-18 13:00:00,688.5,689.1500244140625,687.4749755859375,687.8049926757812,2405600
|
||||
2026-02-18 13:30:00,687.8099975585938,688.02001953125,686.0399780273438,686.97998046875,4123682
|
||||
2026-02-18 14:00:00,686.989990234375,687.3900146484375,686.0700073242188,686.4199829101562,4265139
|
||||
2026-02-18 14:30:00,686.4299926757812,686.6300048828125,684.0499877929688,684.5999755859375,5521451
|
||||
2026-02-18 15:00:00,684.5800170898438,685.3599853515625,683.9199829101562,684.2100219726562,4614814
|
||||
2026-02-18 15:30:00,684.2100219726562,687.1799926757812,684.1199951171875,686.1900024414062,13217594
|
||||
2026-02-19 09:30:00,683.8400268554688,685.8200073242188,682.469970703125,685.010009765625,9809956
|
||||
2026-02-19 10:00:00,685.0,686.1799926757812,683.8099975585938,683.9000244140625,5484387
|
||||
2026-02-19 10:30:00,683.8900146484375,685.8599853515625,683.7100219726562,685.3200073242188,3259468
|
||||
2026-02-19 11:00:00,685.3200073242188,685.8300170898438,683.9000244140625,684.14501953125,2655141
|
||||
2026-02-19 11:30:00,684.1500244140625,685.27001953125,682.719970703125,683.52001953125,3640329
|
||||
2026-02-19 12:00:00,683.5250244140625,684.780029296875,682.7000122070312,683.9299926757812,2960917
|
||||
2026-02-19 12:30:00,683.9149780273438,684.260009765625,682.1300048828125,682.4400024414062,2830254
|
||||
2026-02-19 13:00:00,682.4199829101562,683.4199829101562,681.5474853515625,682.4000244140625,3304400
|
||||
2026-02-19 13:30:00,682.3400268554688,683.8800048828125,682.010009765625,683.739990234375,2668632
|
||||
2026-02-19 14:00:00,683.739990234375,684.0598754882812,683.0,683.2750244140625,2531001
|
||||
2026-02-19 14:30:00,683.25,683.5650024414062,681.8800048828125,682.5599975585938,3425329
|
||||
2026-02-19 15:00:00,682.530029296875,683.489990234375,682.52001953125,682.9000244140625,4468918
|
||||
2026-02-19 15:30:00,682.8900146484375,684.5900268554688,682.8001098632812,684.510009765625,7648304
|
||||
2026-02-20 09:30:00,682.3200073242188,685.3499755859375,681.739990234375,684.8499755859375,9151776
|
||||
2026-02-20 10:00:00,684.8900146484375,689.4000244140625,684.1900024414062,686.7999877929688,11328318
|
||||
2026-02-20 10:30:00,686.7999877929688,689.1699829101562,685.510009765625,688.8300170898438,5860027
|
||||
2026-02-20 11:00:00,688.8200073242188,689.239990234375,686.0900268554688,686.1495971679688,11087726
|
||||
2026-02-20 11:30:00,686.1300048828125,689.1500244140625,685.8900146484375,688.5700073242188,6362378
|
||||
2026-02-20 12:00:00,688.5700073242188,688.8699951171875,684.0800170898438,685.2291870117188,5881952
|
||||
2026-02-20 12:30:00,685.2349853515625,687.7100219726562,684.9700927734375,687.47998046875,4927923
|
||||
2026-02-20 13:00:00,687.4299926757812,687.7999877929688,685.717529296875,686.5599975585938,4217967
|
||||
2026-02-20 13:30:00,686.5399780273438,690.0599975585938,685.760009765625,689.2999877929688,9260818
|
||||
2026-02-20 14:00:00,689.2999877929688,689.7899780273438,688.239990234375,688.5800170898438,3936078
|
||||
2026-02-20 14:30:00,688.5800170898438,689.1199951171875,687.8099975585938,688.0499877929688,3577083
|
||||
2026-02-20 15:00:00,688.0599975585938,689.3200073242188,688.0050048828125,688.3599853515625,4449935
|
||||
2026-02-20 15:30:00,688.3599853515625,689.5,688.27001953125,689.3499755859375,10691604
|
||||
2026-02-23 09:30:00,687.8300170898438,690.0,686.2999877929688,686.530029296875,12006960
|
||||
2026-02-23 10:00:00,686.5,687.22998046875,684.2799072265625,684.510009765625,8045462
|
||||
2026-02-23 10:30:00,684.5,685.0050048828125,682.1799926757812,682.260009765625,7093687
|
||||
2026-02-23 11:00:00,682.27001953125,682.9099731445312,680.3709716796875,682.8699951171875,8309823
|
||||
2026-02-23 11:30:00,682.8800048828125,683.0850219726562,681.780029296875,681.9600219726562,4423053
|
||||
2026-02-23 12:00:00,681.969970703125,682.469970703125,681.5900268554688,681.6900024414062,2659647
|
||||
2026-02-23 12:30:00,681.7000122070312,682.3499755859375,680.5900268554688,682.0449829101562,4149150
|
||||
2026-02-23 13:00:00,682.0399780273438,683.9500122070312,681.9400024414062,683.8098754882812,3525018
|
||||
2026-02-23 13:30:00,683.8099975585938,683.9099731445312,681.8699951171875,682.010009765625,3280170
|
||||
2026-02-23 14:00:00,682.02001953125,682.280029296875,681.1699829101562,681.27001953125,4208010
|
||||
2026-02-23 14:30:00,681.2550048828125,681.6599731445312,680.5399780273438,681.0599975585938,3879543
|
||||
2026-02-23 15:00:00,681.0599975585938,681.52001953125,680.6500244140625,681.3499755859375,4474731
|
||||
2026-02-23 15:30:00,681.3400268554688,682.469970703125,680.9000244140625,682.4099731445312,15739631
|
||||
2026-02-24 09:30:00,681.9000244140625,685.4600219726562,680.0,683.1500244140625,12315459
|
||||
2026-02-24 10:00:00,683.1799926757812,686.3900146484375,682.030029296875,686.3350219726562,6900473
|
||||
2026-02-24 10:30:00,686.3200073242188,686.5449829101562,685.1799926757812,685.3699951171875,5177894
|
||||
2026-02-24 11:00:00,685.3699951171875,686.8699951171875,685.2921142578125,686.0900268554688,3737538
|
||||
2026-02-24 11:30:00,686.0999755859375,686.7000122070312,685.1500244140625,686.469970703125,3211549
|
||||
2026-02-24 12:00:00,686.4600219726562,687.7999877929688,686.4000244140625,687.5300903320312,3558364
|
||||
2026-02-24 12:30:00,687.530029296875,687.6599731445312,686.9619750976562,687.3699951171875,2311462
|
||||
2026-02-24 13:00:00,687.3599853515625,688.0999755859375,686.9400024414062,687.030029296875,2108681
|
||||
2026-02-24 13:30:00,687.0399780273438,687.875,686.905029296875,687.4598999023438,2449007
|
||||
2026-02-24 14:00:00,687.4500122070312,687.5999755859375,686.6500244140625,686.8900146484375,3798412
|
||||
2026-02-24 14:30:00,686.8900146484375,687.6900024414062,686.780029296875,687.135009765625,2689633
|
||||
2026-02-24 15:00:00,687.135009765625,687.3400268554688,686.4600219726562,686.5399780273438,3453132
|
||||
2026-02-24 15:30:00,686.530029296875,688.3499755859375,686.5100708007812,687.375,12579342
|
||||
2026-02-25 09:30:00,690.1799926757812,691.989990234375,690.0999755859375,691.3200073242188,7845111
|
||||
2026-02-25 10:00:00,691.3200073242188,691.5800170898438,690.25,691.1500244140625,4002640
|
||||
2026-02-25 10:30:00,691.14501953125,691.3499755859375,690.1701049804688,690.8400268554688,2547963
|
||||
2026-02-25 11:00:00,690.8300170898438,691.5800170898438,690.6900024414062,691.469970703125,2645311
|
||||
2026-02-25 11:30:00,691.47998046875,692.3900146484375,691.4000244140625,692.0399780273438,3263681
|
||||
2026-02-25 12:00:00,692.0399780273438,692.3499755859375,691.7999877929688,691.969970703125,2214444
|
||||
2026-02-25 12:30:00,691.969970703125,692.5700073242188,691.8400268554688,692.5499877929688,2218727
|
||||
2026-02-25 13:00:00,692.5499877929688,692.6900024414062,692.2050170898438,692.3099975585938,2694920
|
||||
2026-02-25 13:30:00,692.3099975585938,692.6900024414062,692.2100219726562,692.489990234375,2007932
|
||||
2026-02-25 14:00:00,692.489990234375,693.0800170898438,692.47998046875,692.8699951171875,3206296
|
||||
2026-02-25 14:30:00,692.8599853515625,693.3200073242188,692.8499755859375,693.0850219726562,2612958
|
||||
2026-02-25 15:00:00,693.0850219726562,693.3300170898438,692.8099975585938,692.9199829101562,3947813
|
||||
2026-02-25 15:30:00,692.9199829101562,693.6799926757812,692.739990234375,693.0999755859375,10495870
|
||||
2026-02-26 09:30:00,693.280029296875,693.2999877929688,690.4199829101562,690.6699829101562,8814758
|
||||
2026-02-26 10:00:00,690.6799926757812,691.030029296875,684.739990234375,685.6900024414062,11044556
|
||||
2026-02-26 10:30:00,685.6699829101562,688.1799926757812,684.3499755859375,687.7100219726562,7481798
|
||||
2026-02-26 11:00:00,687.719970703125,689.0999755859375,687.4600219726562,688.739990234375,3693800
|
||||
2026-02-26 11:30:00,688.739990234375,688.9199829101562,686.0349731445312,686.25,3917564
|
||||
2026-02-26 12:00:00,686.239990234375,687.0599975585938,686.030029296875,686.2849731445312,2601143
|
||||
2026-02-26 12:30:00,686.25,687.0900268554688,685.010009765625,685.4600219726562,2631031
|
||||
2026-02-26 13:00:00,685.469970703125,685.9550170898438,684.739990234375,685.6699829101562,2885475
|
||||
2026-02-26 13:30:00,685.6599731445312,688.1900024414062,685.4099731445312,687.3900146484375,4488188
|
||||
2026-02-26 14:00:00,687.4099731445312,689.3200073242188,687.1599731445312,688.6099853515625,3927779
|
||||
2026-02-26 14:30:00,688.6199951171875,689.6599731445312,688.469970703125,689.02001953125,3333412
|
||||
2026-02-26 15:00:00,689.010009765625,689.8099975585938,688.614990234375,688.6649780273438,3717526
|
||||
2026-02-26 15:30:00,688.6649780273438,689.5167236328125,688.6199951171875,689.3200073242188,8572144
|
||||
2026-02-27 09:30:00,683.0900268554688,684.25,681.6400146484375,682.8663940429688,11392713
|
||||
2026-02-27 10:00:00,682.8599853515625,685.489990234375,682.6599731445312,684.5294189453125,6379720
|
||||
2026-02-27 10:30:00,684.510009765625,686.0499877929688,683.739990234375,685.219970703125,3556716
|
||||
2026-02-27 11:00:00,685.239990234375,686.0499877929688,684.6599731445312,685.385009765625,3484183
|
||||
2026-02-27 11:30:00,685.3900146484375,686.2899169921875,684.5650024414062,684.8699951171875,3290521
|
||||
2026-02-27 12:00:00,684.8599853515625,686.280029296875,683.2899780273438,684.1400146484375,4210127
|
||||
2026-02-27 12:30:00,684.1300048828125,685.1199951171875,683.4755249023438,684.375,4491724
|
||||
2026-02-27 13:00:00,684.3699951171875,684.6799926757812,683.0599975585938,683.7999877929688,2922758
|
||||
2026-02-27 13:30:00,683.7999877929688,684.239990234375,683.2999877929688,683.9500122070312,2479731
|
||||
2026-02-27 14:00:00,683.9500122070312,684.97998046875,683.47998046875,683.5999755859375,3194256
|
||||
2026-02-27 14:30:00,683.5999755859375,684.030029296875,682.989990234375,683.3499755859375,3404117
|
||||
2026-02-27 15:00:00,683.3400268554688,686.0999755859375,683.3200073242188,685.590087890625,6686482
|
||||
2026-02-27 15:30:00,685.5800170898438,686.8499755859375,684.489990234375,686.280029296875,15978632
|
||||
2026-03-02 09:30:00,678.7000122070312,683.3699951171875,678.02001953125,683.0,13517630
|
||||
2026-03-02 10:00:00,682.9500122070312,685.3599853515625,682.4000244140625,683.4299926757812,8599643
|
||||
2026-03-02 10:30:00,683.4199829101562,684.8400268554688,682.4199829101562,684.2100219726562,5287026
|
||||
2026-03-02 11:00:00,684.1900024414062,685.6699829101562,682.9500122070312,683.219970703125,5536917
|
||||
2026-03-02 11:30:00,683.219970703125,686.969970703125,682.9199829101562,686.2349853515625,5560334
|
||||
2026-03-02 12:00:00,686.2349853515625,687.1900024414062,685.7100219726562,686.4837036132812,3273553
|
||||
2026-03-02 12:30:00,686.47998046875,687.3099975585938,686.0800170898438,687.2199096679688,2519098
|
||||
2026-03-02 13:00:00,687.219970703125,687.4199829101562,686.1002197265625,686.719970703125,2029992
|
||||
2026-03-02 13:30:00,686.75,686.8400268554688,685.2249755859375,685.280029296875,2801097
|
||||
2026-03-02 14:00:00,685.27001953125,688.280029296875,685.1900024414062,688.2000122070312,4229405
|
||||
2026-03-02 14:30:00,688.2100219726562,688.6199951171875,687.969970703125,688.1950073242188,3144600
|
||||
2026-03-02 15:00:00,688.1900024414062,688.3300170898438,684.8900146484375,685.3499755859375,10018761
|
||||
2026-03-02 15:30:00,685.3499755859375,687.3099975585938,685.2000122070312,686.3699951171875,12691271
|
||||
2026-03-03 09:30:00,675.0599975585938,677.0999755859375,671.9500122070312,672.3800048828125,20101160
|
||||
2026-03-03 10:00:00,672.3599853515625,673.9299926757812,669.6599731445312,669.8499755859375,13021771
|
||||
2026-03-03 10:30:00,669.8499755859375,672.5,669.780029296875,671.97998046875,9207591
|
||||
2026-03-03 11:00:00,671.9600219726562,676.5800170898438,671.75,676.02001953125,8016859
|
||||
2026-03-03 11:30:00,676.0,678.4500122070312,674.6599731445312,677.5599975585938,6793621
|
||||
2026-03-03 12:00:00,677.5700073242188,678.489990234375,676.3699951171875,678.3200073242188,3787635
|
||||
2026-03-03 12:30:00,678.3200073242188,678.4739990234375,676.0800170898438,677.4199829101562,3752319
|
||||
2026-03-03 13:00:00,677.3900146484375,680.4299926757812,677.3800048828125,679.8200073242188,3853466
|
||||
2026-03-03 13:30:00,679.8200073242188,681.9099731445312,679.6699829101562,681.260009765625,5499252
|
||||
2026-03-03 14:00:00,681.239990234375,681.52001953125,679.6649780273438,680.5,3859389
|
||||
2026-03-03 14:30:00,680.5,682.6099853515625,679.2750244140625,681.2329711914062,6675010
|
||||
2026-03-03 15:00:00,681.25,681.9500122070312,680.1599731445312,681.0499877929688,4816306
|
||||
2026-03-03 15:30:00,681.0399780273438,681.510009765625,679.2100219726562,680.0399780273438,6319699
|
||||
2026-03-04 09:30:00,681.6300048828125,683.5180053710938,679.6199951171875,681.302978515625,9849889
|
||||
2026-03-04 10:00:00,681.4099731445312,686.27001953125,680.8699951171875,686.1400146484375,7242226
|
||||
2026-03-04 10:30:00,686.1500244140625,686.3099975585938,683.8800048828125,685.8300170898438,6268576
|
||||
2026-03-04 11:00:00,685.8400268554688,686.6400146484375,684.1799926757812,685.1900024414062,6472934
|
||||
2026-03-04 11:30:00,685.1799926757812,686.4000244140625,684.8499755859375,685.780029296875,4337235
|
||||
2026-03-04 12:00:00,685.7949829101562,686.3599853515625,685.3099975585938,685.3250122070312,3369534
|
||||
2026-03-04 12:30:00,685.3499755859375,686.4199829101562,684.5800170898438,686.1900024414062,3863509
|
||||
2026-03-04 13:00:00,686.2000122070312,686.489990234375,685.3800048828125,686.10498046875,3051554
|
||||
2026-03-04 13:30:00,686.1099853515625,686.8699951171875,686.0999755859375,686.5449829101562,2931283
|
||||
2026-03-04 14:00:00,686.5499877929688,686.9600219726562,686.3900146484375,686.5999755859375,2221319
|
||||
2026-03-04 14:30:00,686.6099853515625,687.0900268554688,686.4450073242188,686.8099975585938,2684793
|
||||
2026-03-04 15:00:00,686.8150024414062,687.0050048828125,685.4749755859375,686.22998046875,4228708
|
||||
2026-03-04 15:30:00,686.239990234375,686.6300048828125,685.1599731445312,685.1649780273438,11184616
|
||||
2026-03-05 09:30:00,682.0800170898438,685.1011962890625,681.8699951171875,685.0999755859375,10670019
|
||||
2026-03-05 10:00:00,685.0900268554688,685.530029296875,680.3300170898438,680.8200073242188,9045328
|
||||
2026-03-05 10:30:00,680.8300170898438,682.5800170898438,679.3200073242188,682.1099853515625,7439675
|
||||
2026-03-05 11:00:00,682.0999755859375,682.3599853515625,679.010009765625,679.5349731445312,8573727
|
||||
2026-03-05 11:30:00,679.530029296875,681.3499755859375,679.4600219726562,681.0599975585938,5576246
|
||||
2026-03-05 12:00:00,681.0549926757812,682.4199829101562,678.489990234375,678.510009765625,5386953
|
||||
2026-03-05 12:30:00,678.510009765625,678.8049926757812,676.030029296875,676.4600219726562,7947362
|
||||
2026-03-05 13:00:00,676.4500122070312,678.260009765625,675.9000244140625,676.2999877929688,4862016
|
||||
2026-03-05 13:30:00,676.280029296875,677.9600219726562,675.8200073242188,676.6199951171875,4006006
|
||||
2026-03-05 14:00:00,676.5999755859375,677.7899780273438,676.2899780273438,676.6699829101562,4123417
|
||||
2026-03-05 14:30:00,676.6699829101562,677.72998046875,675.6099853515625,677.0900268554688,4262287
|
||||
2026-03-05 15:00:00,677.0700073242188,680.4600219726562,676.3900146484375,679.4600219726562,8441210
|
||||
2026-03-05 15:30:00,679.469970703125,681.75,679.1699829101562,681.4400024414062,17275765
|
||||
2026-03-06 09:30:00,673.4099731445312,674.0599975585938,669.760009765625,670.8699951171875,18169619
|
||||
2026-03-06 10:00:00,670.9099731445312,674.3599853515625,670.791015625,674.1699829101562,7876730
|
||||
2026-03-06 10:30:00,674.1300048828125,675.9149780273438,672.4600219726562,674.9199829101562,7095966
|
||||
2026-03-06 11:00:00,674.9600219726562,676.010009765625,673.4349975585938,674.4199829101562,4958922
|
||||
2026-03-06 11:30:00,674.4299926757812,676.0,673.4000244140625,674.969970703125,3958006
|
||||
2026-03-06 12:00:00,674.97998046875,675.8400268554688,673.3499755859375,673.8099975585938,3906929
|
||||
2026-03-06 12:30:00,673.7999877929688,674.1599731445312,672.030029296875,673.0800170898438,4299727
|
||||
2026-03-06 13:00:00,673.0900268554688,676.1099853515625,672.969970703125,674.6400146484375,5087813
|
||||
2026-03-06 13:30:00,674.6699829101562,675.2999877929688,673.6900024414062,674.0399780273438,4250499
|
||||
2026-03-06 14:00:00,674.0700073242188,675.4299926757812,673.780029296875,674.9199829101562,3370425
|
||||
2026-03-06 14:30:00,674.9099731445312,675.6400146484375,674.1799926757812,675.010009765625,4736793
|
||||
2026-03-06 15:00:00,675.0,675.969970703125,671.97998046875,672.22998046875,7386159
|
||||
2026-03-06 15:30:00,672.22998046875,673.4000244140625,671.1400146484375,672.489990234375,16549538
|
||||
2026-03-09 09:30:00,666.3900146484375,667.6300048828125,662.7000122070312,664.0800170898438,13542871
|
||||
2026-03-09 10:00:00,664.0499877929688,665.9500122070312,662.3900146484375,664.0599975585938,7962422
|
||||
2026-03-09 10:30:00,664.030029296875,668.3099975585938,663.97998046875,667.5800170898438,6056607
|
||||
2026-03-09 11:00:00,667.5800170898438,669.9199829101562,667.5800170898438,669.77001953125,5489484
|
||||
2026-03-09 11:30:00,669.77001953125,669.8900146484375,668.3699951171875,669.4450073242188,4434002
|
||||
2026-03-09 12:00:00,669.4400024414062,671.5999755859375,669.169921875,670.8699951171875,4434300
|
||||
2026-03-09 12:30:00,670.8699951171875,672.25,669.8900146484375,671.2698974609375,4638323
|
||||
2026-03-09 13:00:00,671.2750244140625,671.3599853515625,668.7100219726562,668.844970703125,4269224
|
||||
2026-03-09 13:30:00,668.8400268554688,669.3400268554688,668.0900268554688,668.8150024414062,3915778
|
||||
2026-03-09 14:00:00,668.8400268554688,670.8699951171875,668.77001953125,670.0599975585938,4276846
|
||||
2026-03-09 14:30:00,670.0599975585938,671.2999877929688,669.9378051757812,670.0800170898438,4130423
|
||||
2026-03-09 15:00:00,670.0999755859375,677.5150146484375,669.9000244140625,677.3900146484375,12772758
|
||||
2026-03-09 15:30:00,677.3699951171875,679.9199829101562,675.8800048828125,678.2999877929688,16099713
|
||||
2026-03-10 09:30:00,677.719970703125,678.7849731445312,675.489990234375,675.8499755859375,9403089
|
||||
2026-03-10 10:00:00,675.8300170898438,678.0499877929688,674.760009765625,677.6199951171875,4681281
|
||||
2026-03-10 10:30:00,677.6400146484375,681.1599731445312,677.6300048828125,681.1400146484375,5042469
|
||||
2026-03-10 11:00:00,681.1300048828125,681.6849975585938,680.22998046875,681.1099853515625,4361068
|
||||
2026-03-10 11:30:00,681.0999755859375,681.9498901367188,680.4400024414062,681.3699951171875,3389571
|
||||
2026-03-10 12:00:00,681.3599853515625,681.4000244140625,679.6799926757812,680.5999755859375,3873397
|
||||
2026-03-10 12:30:00,680.6099853515625,681.8200073242188,680.6099853515625,681.4500122070312,4146661
|
||||
2026-03-10 13:00:00,681.4500122070312,683.3599853515625,681.27001953125,683.2650146484375,4944269
|
||||
2026-03-10 13:30:00,683.25,683.25,680.6300048828125,682.0469970703125,4947798
|
||||
2026-03-10 14:00:00,682.0499877929688,682.1900024414062,677.3800048828125,679.3499755859375,10262003
|
||||
2026-03-10 14:30:00,679.3150024414062,680.3699951171875,678.3800048828125,678.5,4689766
|
||||
2026-03-10 15:00:00,678.52001953125,679.530029296875,677.4099731445312,677.4099731445312,4818131
|
||||
2026-03-10 15:30:00,677.4199829101562,678.3200073242188,676.22998046875,677.0499877929688,10409992
|
||||
2026-03-11 09:30:00,677.5800170898438,678.5,676.1300048828125,677.8900146484375,8160082
|
||||
2026-03-11 10:00:00,677.9000244140625,680.0800170898438,675.1950073242188,676.1500244140625,6569639
|
||||
2026-03-11 10:30:00,676.1400146484375,678.3800048828125,675.2000122070312,678.0700073242188,3429360
|
||||
2026-03-11 11:00:00,678.0800170898438,678.6400146484375,674.5900268554688,674.6699829101562,5050826
|
||||
2026-03-11 11:30:00,674.6799926757812,675.2899780273438,673.6400146484375,674.2017822265625,3751820
|
||||
2026-03-11 12:00:00,674.1900024414062,674.739990234375,673.510009765625,674.7050170898438,3828015
|
||||
2026-03-11 12:30:00,674.7000122070312,676.3200073242188,674.6500244140625,676.2899780273438,2724664
|
||||
2026-03-11 13:00:00,676.239990234375,676.77001953125,675.3300170898438,676.4600219726562,2873175
|
||||
2026-03-11 13:30:00,676.469970703125,676.639892578125,673.3400268554688,674.989990234375,4988527
|
||||
2026-03-11 14:00:00,674.989990234375,675.510009765625,673.7000122070312,675.27001953125,3592706
|
||||
2026-03-11 14:30:00,675.27001953125,675.7899780273438,674.760009765625,675.25,4085110
|
||||
2026-03-11 15:00:00,675.260009765625,675.469970703125,674.3200073242188,674.9749755859375,3957014
|
||||
2026-03-11 15:30:00,674.989990234375,676.5999755859375,674.75,676.3300170898438,7671647
|
||||
2026-03-12 09:30:00,671.1599731445312,671.6500244140625,668.1829833984375,668.8699951171875,13123121
|
||||
2026-03-12 10:00:00,668.8699951171875,671.2000122070312,668.0499877929688,668.0599975585938,7337376
|
||||
2026-03-12 10:30:00,668.0599975585938,670.5499877929688,667.2999877929688,668.5,7850132
|
||||
2026-03-12 11:00:00,668.510009765625,668.7999877929688,666.9199829101562,668.1699829101562,5772318
|
||||
2026-03-12 11:30:00,668.1699829101562,670.3099975585938,667.239990234375,669.2999267578125,8016798
|
||||
2026-03-12 12:00:00,669.3099975585938,671.3099975585938,667.6699829101562,668.8400268554688,6836941
|
||||
2026-03-12 12:30:00,668.77001953125,670.2899780273438,668.0399780273438,668.8400268554688,4544431
|
||||
2026-03-12 13:00:00,668.8300170898438,670.3098754882812,667.989990234375,668.5599975585938,4077906
|
||||
2026-03-12 13:30:00,668.5599975585938,669.75,667.2700805664062,668.1600952148438,5243231
|
||||
2026-03-12 14:00:00,668.1699829101562,669.219970703125,667.6901245117188,668.1900024414062,4572965
|
||||
2026-03-12 14:30:00,668.1799926757812,668.47998046875,667.2000122070312,667.3289794921875,5721278
|
||||
2026-03-12 15:00:00,667.3200073242188,668.3900146484375,666.8699951171875,667.97998046875,7304760
|
||||
2026-03-12 15:30:00,667.989990234375,668.6500244140625,665.905029296875,666.0399780273438,12771977
|
||||
2026-03-13 09:30:00,669.27001953125,672.3350219726562,668.9600219726562,670.530029296875,12011325
|
||||
2026-03-13 10:00:00,670.5399780273438,671.25,667.72998046875,668.840087890625,6206634
|
||||
2026-03-13 10:30:00,668.8699951171875,670.22998046875,664.989990234375,665.3300170898438,7003488
|
||||
2026-03-13 11:00:00,665.3400268554688,666.9400024414062,664.4299926757812,664.5900268554688,6813713
|
||||
2026-03-13 11:30:00,664.5700073242188,665.3300170898438,663.0999755859375,663.3800048828125,6424291
|
||||
2026-03-13 12:00:00,663.3599853515625,664.9650268554688,663.3400268554688,664.3300170898438,4488838
|
||||
2026-03-13 12:30:00,664.3200073242188,665.22998046875,663.135009765625,663.3350219726562,4985126
|
||||
2026-03-13 13:00:00,663.3200073242188,665.0800170898438,663.1300048828125,664.3699951171875,3772410
|
||||
2026-03-13 13:30:00,664.3499755859375,664.52001953125,662.0800170898438,662.6300048828125,5456912
|
||||
2026-03-13 14:00:00,662.6400146484375,663.72998046875,662.4500122070312,663.3400268554688,3983028
|
||||
2026-03-13 14:30:00,663.3300170898438,664.8099975585938,663.0999755859375,664.1799926757812,3489301
|
||||
2026-03-13 15:00:00,664.1799926757812,664.2000122070312,662.1599731445312,662.3699951171875,6013854
|
||||
2026-03-13 15:30:00,662.3699951171875,663.0,661.3599853515625,662.2999877929688,11375885
|
||||
2026-03-16 09:30:00,668.3800048828125,670.9000244140625,667.7000122070312,670.7999877929688,11920187
|
||||
2026-03-16 10:00:00,670.7999877929688,671.4000244140625,669.989990234375,670.27001953125,5814198
|
||||
2026-03-16 10:30:00,670.27001953125,670.3499755859375,667.5750122070312,669.8300170898438,6631428
|
||||
2026-03-16 11:00:00,669.8400268554688,670.3200073242188,668.6199951171875,670.0599975585938,3835303
|
||||
2026-03-16 11:30:00,670.0399780273438,670.6400146484375,668.4400024414062,668.469970703125,3881241
|
||||
2026-03-16 12:00:00,668.469970703125,669.1900024414062,667.1500244140625,667.4500122070312,7640716
|
||||
2026-03-16 12:30:00,667.4199829101562,669.073974609375,667.1199951171875,668.4249877929688,2896652
|
||||
2026-03-16 13:00:00,668.4299926757812,669.75,668.406005859375,669.2000122070312,3136895
|
||||
2026-03-16 13:30:00,669.2000122070312,669.5599975585938,667.97998046875,668.7550048828125,2786763
|
||||
2026-03-16 14:00:00,668.760009765625,670.0800170898438,668.6599731445312,669.7999877929688,5673034
|
||||
2026-03-16 14:30:00,669.7999877929688,670.3099975585938,669.489990234375,669.60498046875,3808006
|
||||
2026-03-16 15:00:00,669.60498046875,672.0700073242188,668.6900024414062,669.27001953125,7795060
|
||||
2026-03-16 15:30:00,669.27001953125,669.4299926757812,668.4199829101562,668.9600219726562,10497369
|
||||
2026-03-17 09:30:00,672.3900146484375,674.4299926757812,671.9099731445312,673.8900146484375,9276301
|
||||
2026-03-17 10:00:00,673.8800048828125,673.9099731445312,671.5599975585938,672.5599975585938,6014062
|
||||
2026-03-17 10:30:00,672.5700073242188,673.469970703125,672.1400146484375,673.030029296875,3356916
|
||||
2026-03-17 11:00:00,673.02001953125,673.27001953125,671.5999755859375,671.635009765625,4498373
|
||||
2026-03-17 11:30:00,671.6300048828125,672.1900024414062,670.2999877929688,671.010009765625,5317857
|
||||
2026-03-17 12:00:00,671.02001953125,671.5900268554688,669.7000122070312,671.2100219726562,4547148
|
||||
2026-03-17 12:30:00,671.2000122070312,671.6400146484375,670.719970703125,670.9500122070312,2581648
|
||||
2026-03-17 13:00:00,670.9400024414062,671.9400024414062,670.9299926757812,671.530029296875,2553434
|
||||
2026-03-17 13:30:00,671.530029296875,671.9749755859375,671.030029296875,671.7000122070312,2665042
|
||||
2026-03-17 14:00:00,671.7000122070312,672.2999877929688,670.8599853515625,670.9000244140625,5580685
|
||||
2026-03-17 14:30:00,670.9000244140625,671.4400024414062,670.739990234375,671.0899047851562,3439269
|
||||
2026-03-17 15:00:00,671.0900268554688,671.60498046875,670.5900268554688,671.0399780273438,4892325
|
||||
2026-03-17 15:30:00,671.0399780273438,671.280029296875,670.469970703125,670.72998046875,10144142
|
||||
2026-03-18 09:30:00,668.3599853515625,669.719970703125,667.8800048828125,669.280029296875,11319846
|
||||
2026-03-18 10:00:00,669.280029296875,669.4099731445312,665.719970703125,665.9600219726562,5097139
|
||||
2026-03-18 10:30:00,665.969970703125,667.155029296875,665.6599731445312,667.1400146484375,4118025
|
||||
2026-03-18 11:00:00,667.1300048828125,668.0,666.4000244140625,666.9342041015625,3556343
|
||||
2026-03-18 11:30:00,666.9099731445312,667.6199951171875,666.6699829101562,667.0399780273438,2637483
|
||||
2026-03-18 12:00:00,667.030029296875,667.5800170898438,666.4918212890625,667.0700073242188,2075487
|
||||
2026-03-18 12:30:00,667.0700073242188,667.2960205078125,665.8300170898438,666.5499877929688,2369646
|
||||
2026-03-18 13:00:00,666.5399780273438,666.6300048828125,665.4299926757812,665.8400268554688,3112601
|
||||
2026-03-18 13:30:00,665.8400268554688,666.8699951171875,665.2899780273438,666.719970703125,3091569
|
||||
2026-03-18 14:00:00,666.7100219726562,667.4400024414062,665.3800048828125,666.1199951171875,4111070
|
||||
2026-03-18 14:30:00,666.1099853515625,666.2999877929688,663.3200073242188,663.4099731445312,6801749
|
||||
2026-03-18 15:00:00,663.4000244140625,664.719970703125,662.6799926757812,662.9099731445312,7921701
|
||||
2026-03-18 15:30:00,662.9000244140625,663.2191772460938,661.1900024414062,661.5700073242188,14238601
|
||||
2026-03-19 09:30:00,656.969970703125,658.9600219726562,655.1699829101562,658.6799926757812,17186363
|
||||
2026-03-19 10:00:00,658.6599731445312,659.469970703125,656.0399780273438,656.3300170898438,6424002
|
||||
2026-03-19 10:30:00,656.3400268554688,659.0640258789062,656.260009765625,657.7985229492188,5383915
|
||||
2026-03-19 11:00:00,657.7899780273438,659.7100219726562,657.2100219726562,657.719970703125,6792002
|
||||
2026-03-19 11:30:00,657.719970703125,657.9500122070312,656.1799926757812,657.9249877929688,6430177
|
||||
2026-03-19 12:00:00,657.9099731445312,659.0399780273438,657.1199951171875,657.9500122070312,5450047
|
||||
2026-03-19 12:30:00,657.97998046875,658.7100219726562,656.6099853515625,657.1680297851562,4799429
|
||||
2026-03-19 13:00:00,657.1599731445312,657.5499877929688,655.6099853515625,657.1199951171875,4594504
|
||||
2026-03-19 13:30:00,657.0999755859375,657.8800048828125,656.1599731445312,657.239990234375,3777320
|
||||
2026-03-19 14:00:00,657.22998046875,658.6199951171875,656.6300048828125,657.1749877929688,5363917
|
||||
2026-03-19 14:30:00,657.1799926757812,658.8099975585938,656.3200073242188,658.4500122070312,5554811
|
||||
2026-03-19 15:00:00,658.4400024414062,662.97998046875,658.3900146484375,660.25,16369754
|
||||
2026-03-19 15:30:00,660.239990234375,660.739990234375,658.97998046875,659.719970703125,11501098
|
||||
2026-03-20 09:30:00,656.510009765625,656.6900024414062,653.0,653.1500244140625,13932173
|
||||
2026-03-20 10:00:00,653.1699829101562,653.8110961914062,651.47998046875,651.8599853515625,7781700
|
||||
2026-03-20 10:30:00,651.8599853515625,653.5,651.2000122070312,652.1199951171875,17849813
|
||||
2026-03-20 11:00:00,652.1099853515625,654.1599731445312,651.5,651.9199829101562,6443642
|
||||
2026-03-20 11:30:00,651.9400024414062,653.260009765625,651.72998046875,652.9450073242188,8483933
|
||||
2026-03-20 12:00:00,652.969970703125,653.989990234375,652.780029296875,653.0999755859375,6873566
|
||||
2026-03-20 12:30:00,653.0999755859375,653.4000244140625,651.9500122070312,652.77001953125,4407474
|
||||
2026-03-20 13:00:00,652.760009765625,653.25,651.75,652.1599731445312,4777130
|
||||
2026-03-20 13:30:00,652.1900024414062,652.3699951171875,650.5499877929688,651.4000244140625,5026757
|
||||
2026-03-20 14:00:00,651.4099731445312,651.6950073242188,648.6599731445312,649.4299926757812,10109481
|
||||
2026-03-20 14:30:00,649.4299926757812,649.655029296875,647.6400146484375,648.0800170898438,9537411
|
||||
2026-03-20 15:00:00,648.0709838867188,648.64501953125,646.780029296875,647.1199951171875,11826121
|
||||
2026-03-20 15:30:00,647.1300048828125,648.52001953125,644.719970703125,648.510009765625,27843185
|
||||
2026-03-23 09:30:00,658.0700073242188,660.25,656.0700073242188,660.219970703125,28302521
|
||||
2026-03-23 10:00:00,660.2000122070312,662.27001953125,658.5800170898438,660.989990234375,10460585
|
||||
2026-03-23 10:30:00,660.97998046875,661.7999877929688,659.9500122070312,661.0499877929688,5831335
|
||||
2026-03-23 11:00:00,661.0700073242188,662.614990234375,660.7000122070312,660.8900146484375,6990174
|
||||
2026-03-23 11:30:00,660.9000244140625,661.3300170898438,655.8300170898438,656.010009765625,11768905
|
||||
2026-03-23 12:00:00,655.989990234375,657.6300048828125,653.9400024414062,654.0900268554688,7951586
|
||||
2026-03-23 12:30:00,654.1088256835938,656.739990234375,653.989990234375,655.5900268554688,5462108
|
||||
2026-03-23 13:00:00,655.5800170898438,656.25,654.3099975585938,655.280029296875,3643061
|
||||
2026-03-23 13:30:00,655.280029296875,658.25,655.260009765625,658.0599975585938,5759300
|
||||
2026-03-23 14:00:00,658.0900268554688,658.3099975585938,656.530029296875,657.3900146484375,5101423
|
||||
2026-03-23 14:30:00,657.4099731445312,659.6500244140625,657.4099731445312,658.9500122070312,6694821
|
||||
2026-03-23 15:00:00,658.9500122070312,659.02001953125,657.030029296875,657.3300170898438,4990915
|
||||
2026-03-23 15:30:00,657.3200073242188,658.3499755859375,655.3699951171875,655.3699951171875,13898637
|
||||
2026-03-24 09:30:00,651.3200073242188,653.0399780273438,649.8800048828125,651.47998046875,10772865
|
||||
2026-03-24 10:00:00,651.47998046875,653.585693359375,651.2261962890625,652.489990234375,6384275
|
||||
2026-03-24 10:30:00,652.510009765625,655.9400024414062,651.219970703125,653.9400024414062,9976408
|
||||
2026-03-24 11:00:00,653.97998046875,656.8300170898438,653.9199829101562,656.0399780273438,6092978
|
||||
2026-03-24 11:30:00,656.0599975585938,657.030029296875,654.9099731445312,655.0399780273438,3672450
|
||||
2026-03-24 12:00:00,655.0499877929688,656.25,654.2999877929688,655.9600219726562,4405909
|
||||
2026-03-24 12:30:00,655.969970703125,656.1199951171875,654.0399780273438,655.1500244140625,3131337
|
||||
2026-03-24 13:00:00,655.1300048828125,655.3900146484375,652.5499877929688,653.405029296875,5179672
|
||||
2026-03-24 13:30:00,653.4000244140625,654.3400268554688,652.3800048828125,654.2999877929688,5046434
|
||||
2026-03-24 14:00:00,654.2999877929688,654.97998046875,653.2000122070312,654.5900268554688,5000888
|
||||
2026-03-24 14:30:00,654.5900268554688,656.5900268554688,654.280029296875,654.8599853515625,5716834
|
||||
2026-03-24 15:00:00,654.8300170898438,655.6900024414062,654.3099975585938,654.4500122070312,4244818
|
||||
2026-03-24 15:30:00,654.4500122070312,654.7750244140625,652.5599975585938,653.2000122070312,10043583
|
||||
2026-03-25 09:30:00,658.6699829101562,660.8900146484375,656.4199829101562,656.489990234375,14170187
|
||||
2026-03-25 10:00:00,656.5599975585938,660.5,655.6699829101562,658.1400146484375,8706025
|
||||
2026-03-25 10:30:00,658.1599731445312,659.25,657.6500244140625,658.6900024414062,5082709
|
||||
2026-03-25 11:00:00,658.6900024414062,659.0078125,654.239990234375,657.8400268554688,9494826
|
||||
2026-03-25 11:30:00,657.8499755859375,658.239990234375,656.1199951171875,657.0,5217916
|
||||
2026-03-25 12:00:00,657.0,658.0700073242188,656.010009765625,657.5,4334734
|
||||
2026-03-25 12:30:00,657.4600219726562,658.3150024414062,657.0599975585938,657.75,4652674
|
||||
2026-03-25 13:00:00,657.75,658.3200073242188,656.9149780273438,658.02001953125,4083255
|
||||
2026-03-25 13:30:00,658.0399780273438,659.3800048828125,657.3300170898438,657.9000244140625,5590359
|
||||
2026-03-25 14:00:00,657.8900146484375,658.510009765625,657.4099731445312,657.5150146484375,3271887
|
||||
2026-03-25 14:30:00,657.510009765625,657.8099975585938,655.6400146484375,656.5,5186185
|
||||
2026-03-25 15:00:00,656.489990234375,657.3201293945312,655.5800170898438,656.510009765625,6317455
|
||||
2026-03-25 15:30:00,656.510009765625,657.0599975585938,655.7550048828125,656.739990234375,9045207
|
||||
2026-03-26 09:30:00,652.0599975585938,654.6300048828125,650.72998046875,654.469970703125,13926765
|
||||
2026-03-26 10:00:00,654.469970703125,654.8400268554688,653.364990234375,653.6199951171875,5285650
|
||||
2026-03-26 10:30:00,653.6199951171875,654.8499755859375,650.8200073242188,652.2100219726562,9302797
|
||||
2026-03-26 11:00:00,652.219970703125,652.9099731445312,651.5800170898438,651.7100219726562,5560880
|
||||
2026-03-26 11:30:00,651.7100219726562,652.3499755859375,650.8699951171875,651.52001953125,4259219
|
||||
2026-03-26 12:00:00,651.489990234375,651.6900024414062,649.2899780273438,649.469970703125,5418585
|
||||
2026-03-26 12:30:00,649.4912719726562,649.719970703125,648.8900146484375,649.6400146484375,3519101
|
||||
2026-03-26 13:00:00,649.6400146484375,650.0999755859375,647.97998046875,648.6199951171875,3483274
|
||||
2026-03-26 13:30:00,648.6199951171875,649.0599975585938,646.4400024414062,646.6699829101562,5297883
|
||||
2026-03-26 14:00:00,646.655029296875,647.5700073242188,646.489990234375,646.5999755859375,5021873
|
||||
2026-03-26 14:30:00,646.6099853515625,647.6500244140625,646.5800170898438,647.4299926757812,5489198
|
||||
2026-03-26 15:00:00,647.4400024414062,647.52001953125,645.8499755859375,646.2100219726562,6513076
|
||||
2026-03-26 15:30:00,646.2100219726562,646.27001953125,644.8200073242188,645.10498046875,13326642
|
||||
2026-03-27 09:30:00,642.5,642.6599731445312,638.593994140625,639.0499877929688,12919258
|
||||
2026-03-27 10:00:00,639.030029296875,640.7999877929688,638.6599731445312,639.0999755859375,6595156
|
||||
2026-03-27 10:30:00,639.0999755859375,640.5880126953125,638.2999877929688,640.1900024414062,4570242
|
||||
2026-03-27 11:00:00,640.1900024414062,641.489990234375,638.9401245117188,640.9600219726562,6539241
|
||||
2026-03-27 11:30:00,640.9600219726562,642.1400146484375,640.1099853515625,640.280029296875,4787677
|
||||
2026-03-27 12:00:00,640.2999877929688,640.4299926757812,638.5800170898438,638.8900146484375,4244393
|
||||
2026-03-27 12:30:00,638.8800048828125,639.6300048828125,638.0800170898438,638.7000122070312,4548141
|
||||
2026-03-27 13:00:00,638.7000122070312,639.9299926757812,636.5900268554688,636.6199951171875,6145318
|
||||
2026-03-27 13:30:00,636.6199951171875,637.25,635.9400024414062,636.1300048828125,4731959
|
||||
2026-03-27 14:00:00,636.1397705078125,636.3400268554688,635.1300048828125,635.77001953125,5909420
|
||||
2026-03-27 14:30:00,635.7650146484375,636.7249755859375,635.2000122070312,635.3800048828125,5524934
|
||||
2026-03-27 15:00:00,635.3800048828125,635.6799926757812,634.52001953125,634.8590087890625,7965009
|
||||
2026-03-27 15:30:00,634.8599853515625,635.3499755859375,633.10498046875,634.0800170898438,16949297
|
||||
|
3121
openbb_platform/extensions/technical/integration/fixtures/SPY_5m.csv
vendored
Normal file
3121
openbb_platform/extensions/technical/integration/fixtures/SPY_5m.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3484
openbb_platform/extensions/technical/integration/fixtures/SPY_60m.csv
vendored
Normal file
3484
openbb_platform/extensions/technical/integration/fixtures/SPY_60m.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
48031
openbb_platform/extensions/technical/integration/fixtures/multi.csv
vendored
Normal file
48031
openbb_platform/extensions/technical/integration/fixtures/multi.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
"""Technical Analysis Helpers."""
|
||||
|
||||
# pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from warnings import warn
|
||||
|
||||
@@ -50,7 +48,6 @@ def parkinson(
|
||||
DataFrame : results
|
||||
Dataframe with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log
|
||||
|
||||
if window < 1:
|
||||
@@ -106,7 +103,6 @@ def standard_deviation(
|
||||
DataFrame : results
|
||||
Dataframe with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log, sqrt
|
||||
|
||||
if window < 2:
|
||||
@@ -162,7 +158,6 @@ def garman_klass(
|
||||
DataFrame : results
|
||||
Dataframe with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log
|
||||
|
||||
if window < 1:
|
||||
@@ -226,7 +221,6 @@ def hodges_tompkins(
|
||||
>>> data = obb.equity.price.historical('BTC-USD')
|
||||
>>> df = obb.technical.hodges_tompkins(data, is_crypto = True)
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log, sqrt
|
||||
|
||||
if window < 2:
|
||||
@@ -287,7 +281,6 @@ def rogers_satchell(
|
||||
Series : results
|
||||
Pandas Series with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log
|
||||
|
||||
if window < 1:
|
||||
@@ -347,7 +340,6 @@ def yang_zhang(
|
||||
DataFrame : results
|
||||
Dataframe with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log, sqrt
|
||||
|
||||
if window < 2:
|
||||
@@ -407,7 +399,6 @@ def calculate_cones(
|
||||
trading_periods: int | None = None,
|
||||
) -> "DataFrame":
|
||||
"""Calculate Cones."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from pandas import DataFrame
|
||||
|
||||
estimator = DataFrame()
|
||||
@@ -441,7 +432,7 @@ def calculate_cones(
|
||||
}
|
||||
|
||||
for window in windows:
|
||||
estimator = model_functions[model]( # type: ignore
|
||||
estimator = model_functions[model](
|
||||
window=window,
|
||||
data=data,
|
||||
is_crypto=is_crypto,
|
||||
@@ -451,18 +442,18 @@ def calculate_cones(
|
||||
if estimator.empty:
|
||||
continue
|
||||
|
||||
min_.append(estimator.min()) # type: ignore
|
||||
max_.append(estimator.max()) # type: ignore
|
||||
median.append(estimator.median()) # type: ignore
|
||||
top_q.append(estimator.quantile(quantiles[1])) # type: ignore
|
||||
bottom_q.append(estimator.quantile(quantiles[0])) # type: ignore
|
||||
realized.append(estimator.iloc[-1]) # type: ignore
|
||||
min_.append(estimator.min())
|
||||
max_.append(estimator.max())
|
||||
median.append(estimator.median())
|
||||
top_q.append(estimator.quantile(quantiles[1]))
|
||||
bottom_q.append(estimator.quantile(quantiles[0]))
|
||||
realized.append(estimator.iloc[-1])
|
||||
|
||||
allowed_windows.append(window)
|
||||
|
||||
df_ = [realized, min_, bottom_q, median, top_q, max_]
|
||||
df_windows = allowed_windows
|
||||
df = DataFrame(df_, columns=df_windows)
|
||||
df = DataFrame(df_, columns=df_windows) # ty: ignore[invalid-argument-type]
|
||||
df = df.rename(
|
||||
index={
|
||||
0: "realized",
|
||||
@@ -501,7 +492,6 @@ def clenow_momentum(
|
||||
Series:
|
||||
Values for best fit line
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import arange, exp, log
|
||||
from pandas import Series
|
||||
from sklearn.linear_model import LinearRegression
|
||||
@@ -512,7 +502,7 @@ def clenow_momentum(
|
||||
values = values[-window:]
|
||||
|
||||
y = log(values)
|
||||
X = arange(len(y)).reshape(-1, 1) # pylint: disable=invalid-name
|
||||
X = arange(len(y)).reshape(-1, 1)
|
||||
|
||||
lr = LinearRegression()
|
||||
lr.fit(X, y)
|
||||
@@ -559,7 +549,6 @@ def calculate_fib_levels(
|
||||
max_pr: float
|
||||
Price at max point
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from pandas import DataFrame
|
||||
|
||||
if close_col not in data.columns:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Indicators sub-package."""
|
||||
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.indicators import (
|
||||
oscillators,
|
||||
overlays,
|
||||
relative_rotation,
|
||||
statistics,
|
||||
structure,
|
||||
trend,
|
||||
volatility,
|
||||
volume,
|
||||
)
|
||||
|
||||
router = Router(prefix="", description="Technical indicators.")
|
||||
router.include_router(overlays.router)
|
||||
router.include_router(oscillators.router)
|
||||
router.include_router(trend.router)
|
||||
router.include_router(volume.router)
|
||||
router.include_router(volatility.router)
|
||||
router.include_router(structure.router)
|
||||
router.include_router(statistics.router)
|
||||
router.include_router(relative_rotation.router)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"overlays",
|
||||
"oscillators",
|
||||
"relative_rotation",
|
||||
"router",
|
||||
"statistics",
|
||||
"structure",
|
||||
"trend",
|
||||
"volatility",
|
||||
"volume",
|
||||
]
|
||||
@@ -0,0 +1,880 @@
|
||||
"""Oscillator-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveFloat, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Oscillator indicators.")
|
||||
|
||||
|
||||
class RsiQueryParams(QueryParams):
|
||||
"""Query parameters for the Relative Strength Index endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Target column to apply RSI to, by default ``"close"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window in bars, by default 14.
|
||||
scalar : PositiveFloat, optional
|
||||
Output scaling. ``100`` produces the conventional 0–100 range,
|
||||
by default 100.0.
|
||||
drift : PositiveInt, optional
|
||||
Difference period for momentum, by default 1.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "rsi")
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Target column to apply RSI to.")
|
||||
length: PositiveInt = Field(default=14, description="Lookback window in bars.")
|
||||
scalar: PositiveFloat = Field(
|
||||
default=100.0,
|
||||
description="Output scaling. ``100`` produces the conventional 0–100 range.",
|
||||
)
|
||||
drift: PositiveInt = Field(default=1, description="Difference period for momentum.")
|
||||
|
||||
|
||||
class RsiData(Data):
|
||||
"""One row of the RSI time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
rsi : float, optional
|
||||
RSI value over the trailing ``length`` bars on ``target``. ``None``
|
||||
for warm-up rows preceding ``length``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
rsi: float | None = Field(
|
||||
description="RSI value over the trailing ``length`` bars on ``target``.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="RSI(14) on daily TSLA closes.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"rsi = obb.technical.rsi(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 2, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def rsi(params: RsiQueryParams) -> OBBject[list[RsiData]]:
|
||||
"""Calculate the Relative Strength Index (RSI), Wilder's momentum oscillator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.rsi(
|
||||
close=params.target,
|
||||
length=params.length,
|
||||
scalar=params.scalar,
|
||||
drift=params.drift,
|
||||
)
|
||||
out = (
|
||||
pd.DataFrame({"rsi": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[RsiData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class StochQueryParams(QueryParams):
|
||||
"""Query parameters for the Stochastic Oscillator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast_k_period : PositiveInt, optional
|
||||
Fast %K lookback, by default 14.
|
||||
slow_d_period : PositiveInt, optional
|
||||
Slow %D smoothing, by default 3.
|
||||
slow_k_period : PositiveInt, optional
|
||||
Slow %K smoothing, by default 3.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "k", "d")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
fast_k_period: PositiveInt = Field(default=14, description="Fast %K lookback.")
|
||||
slow_d_period: PositiveInt = Field(default=3, description="Slow %D smoothing.")
|
||||
slow_k_period: PositiveInt = Field(default=3, description="Slow %K smoothing.")
|
||||
|
||||
|
||||
class StochData(Data):
|
||||
"""One row of the Stochastic Oscillator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
k : float, optional
|
||||
Slow %K — current close relative to the recent high/low range,
|
||||
smoothed by ``slow_k_period``.
|
||||
d : float, optional
|
||||
Slow %D — moving average of %K over ``slow_d_period`` bars.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
k: float | None = Field(description="%K — current close vs. recent range.")
|
||||
d: float | None = Field(description="%D — moving average of %K.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Stochastic oscillator on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"stoch = obb.technical.stoch(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def stoch(params: StochQueryParams) -> OBBject[list[StochData]]:
|
||||
"""Calculate the Stochastic Oscillator (%K and %D)."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(
|
||||
params.data,
|
||||
[params.fast_k_period, params.slow_d_period, params.slow_k_period],
|
||||
)
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
table = df.ta.stoch(
|
||||
k=params.fast_k_period,
|
||||
d=params.slow_d_period,
|
||||
smooth_k=params.slow_k_period,
|
||||
)
|
||||
k_col = (
|
||||
f"STOCHk_{params.fast_k_period}_{params.slow_d_period}_{params.slow_k_period}"
|
||||
)
|
||||
d_col = (
|
||||
f"STOCHd_{params.fast_k_period}_{params.slow_d_period}_{params.slow_k_period}"
|
||||
)
|
||||
out = (
|
||||
table[[k_col, d_col]]
|
||||
.rename(columns={k_col: "k", d_col: "d"})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[StochData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class CciQueryParams(QueryParams):
|
||||
"""Query parameters for the Commodity Channel Index endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window in bars, by default 14.
|
||||
scalar : PositiveFloat, optional
|
||||
Mean-deviation scaling factor (Lambert's original constant),
|
||||
by default 0.015.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "cci")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Lookback window in bars.")
|
||||
scalar: PositiveFloat = Field(
|
||||
default=0.015,
|
||||
description="Mean-deviation scaling factor (Lambert's original constant).",
|
||||
)
|
||||
|
||||
|
||||
class CciData(Data):
|
||||
"""One row of the CCI time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
cci : float, optional
|
||||
CCI value over the trailing ``length`` bars. ``None`` for warm-up
|
||||
rows preceding ``length``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
cci: float | None = Field(
|
||||
description="CCI value over the trailing ``length`` bars."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="CCI(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"cci = obb.technical.cci(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 2, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def cci(params: CciQueryParams) -> OBBject[list[CciData]]:
|
||||
"""Calculate the Commodity Channel Index (CCI), Lambert's mean-reversion oscillator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.cci(length=params.length, scalar=params.scalar)
|
||||
out = (
|
||||
pd.DataFrame({"cci": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[CciData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class FisherQueryParams(QueryParams):
|
||||
"""Query parameters for the Fisher Transform endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high`` and ``low`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Fisher transform window, by default 14.
|
||||
signal : PositiveInt, optional
|
||||
Lag for the signal line, by default 1.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "fisher", "signal")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series (uses high + low).")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Fisher transform window.")
|
||||
signal: PositiveInt = Field(default=1, description="Lag for the signal line.")
|
||||
|
||||
|
||||
class FisherData(Data):
|
||||
"""One row of the Fisher Transform output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
fisher : float, optional
|
||||
Fisher Transform value over the trailing ``length`` bars.
|
||||
signal : float, optional
|
||||
Lagged Fisher signal line trailing ``fisher`` by ``signal`` bars.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
fisher: float | None = Field(description="Fisher Transform value.")
|
||||
signal: float | None = Field(description="Lagged Fisher signal line.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Fisher Transform on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"fisher = obb.technical.fisher(data=data, length=14, signal=1)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 2, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def fisher(params: FisherQueryParams) -> OBBject[list[FisherData]]:
|
||||
"""Calculate Ehlers' Fisher Transform of price with a signal line."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length, params.signal])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
table = df.ta.fisher(length=params.length, signal=params.signal)
|
||||
fisher_col = f"FISHERT_{params.length}_{params.signal}"
|
||||
signal_col = f"FISHERTs_{params.length}_{params.signal}"
|
||||
out = (
|
||||
table[[fisher_col, signal_col]]
|
||||
.rename(columns={fisher_col: "fisher", signal_col: "signal"})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[FisherData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class CgQueryParams(QueryParams):
|
||||
"""Query parameters for the Center of Gravity endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Price series. The implementation uses the median of high/low/close.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window in bars, by default 14.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "cg")
|
||||
|
||||
data: list[Data] = Field(description="Price series (uses high/low/close median).")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Lookback window in bars.")
|
||||
|
||||
|
||||
class CgData(Data):
|
||||
"""One row of the Center of Gravity output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
cg : float, optional
|
||||
Center of Gravity value over the trailing ``length`` bars.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
cg: float | None = Field(description="Center of Gravity value.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Center of Gravity on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"cg = obb.technical.cg(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 2, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def cg(params: CgQueryParams) -> OBBject[list[CgData]]:
|
||||
"""Calculate Ehlers' Center of Gravity oscillator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.cg(length=params.length)
|
||||
out = (
|
||||
pd.DataFrame({"cg": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[CgData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class WilliamsRQueryParams(QueryParams):
|
||||
"""Query parameters for the Williams %R endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window in bars, by default 14.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "williams_r")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Lookback window in bars.")
|
||||
|
||||
|
||||
class WilliamsRData(Data):
|
||||
"""One row of the Williams %R output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
williams_r : float, optional
|
||||
Williams %R — close relative to the trailing high/low range, scaled
|
||||
to the range ``[-100, 0]``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
williams_r: float | None = Field(
|
||||
description="Williams %R — close relative to the trailing high/low range (−100 to 0).",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Williams %R on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"wr = obb.technical.williams_r(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 14, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def williams_r(params: WilliamsRQueryParams) -> OBBject[list[WilliamsRData]]:
|
||||
"""Calculate Williams %R, a momentum oscillator inverted from fast stochastic %K."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.willr(length=params.length)
|
||||
out = (
|
||||
pd.DataFrame({"williams_r": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(
|
||||
results=[WilliamsRData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
class MfiQueryParams(QueryParams):
|
||||
"""Query parameters for the Money Flow Index endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLCV price series. ``high``, ``low``, ``close``, and ``volume`` are
|
||||
required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window in bars, by default 14.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "mfi")
|
||||
|
||||
data: list[Data] = Field(description="OHLCV price series — ``volume`` is required.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Lookback window in bars.")
|
||||
|
||||
|
||||
class MfiData(Data):
|
||||
"""One row of the Money Flow Index output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
mfi : float, optional
|
||||
Money Flow Index — volume-weighted RSI on typical price, scaled to
|
||||
the 0–100 range.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
mfi: float | None = Field(
|
||||
description="Money Flow Index — volume-weighted RSI on typical price (0–100).",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="MFI(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"mfi = obb.technical.mfi(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"length": 14, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def mfi(params: MfiQueryParams) -> OBBject[list[MfiData]]:
|
||||
"""Calculate the Money Flow Index (MFI), a volume-weighted RSI."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.mfi(length=params.length)
|
||||
out = (
|
||||
pd.DataFrame({"mfi": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[MfiData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class TrixQueryParams(QueryParams):
|
||||
"""Query parameters for the TRIX endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Target column to apply TRIX to, by default ``"close"``.
|
||||
length : PositiveInt, optional
|
||||
Triple-EMA window, by default 30.
|
||||
signal : PositiveInt, optional
|
||||
Signal line EMA length, by default 9.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "trix", "signal")
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
target: str = Field(default="close", description="Target column to apply TRIX to.")
|
||||
length: PositiveInt = Field(default=30, description="Triple-EMA window.")
|
||||
signal: PositiveInt = Field(default=9, description="Signal line EMA length.")
|
||||
|
||||
|
||||
class TrixData(Data):
|
||||
"""One row of the TRIX output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
trix : float, optional
|
||||
1-day rate of change of a triple-EMA of ``target``.
|
||||
signal : float, optional
|
||||
EMA of TRIX over ``signal`` bars.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
trix: float | None = Field(description="1-day ROC of a triple-EMA of ``target``.")
|
||||
signal: float | None = Field(description="EMA of TRIX, length ``signal``.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="TRIX(30, 9) on daily TSLA closes.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"trix = obb.technical.trix(data=data, length=30, signal=9)",
|
||||
],
|
||||
),
|
||||
APIEx(
|
||||
parameters={"length": 5, "signal": 3, "data": APIEx.mock_data("timeseries")}
|
||||
),
|
||||
],
|
||||
)
|
||||
def trix(params: TrixQueryParams) -> OBBject[list[TrixData]]:
|
||||
"""Calculate TRIX — the rate of change of a triple-smoothed EMA — with signal."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length, params.signal])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
target_df = df[[params.target]]
|
||||
table = target_df.ta.trix(
|
||||
close=params.target, length=params.length, signal=params.signal
|
||||
)
|
||||
trix_col = f"TRIX_{params.length}_{params.signal}"
|
||||
signal_col = f"TRIXs_{params.length}_{params.signal}"
|
||||
out = (
|
||||
table[[trix_col, signal_col]]
|
||||
.rename(columns={trix_col: "trix", signal_col: "signal"})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[TrixData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class UltimateOscillatorQueryParams(QueryParams):
|
||||
"""Query parameters for Larry Williams' Ultimate Oscillator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast : PositiveInt, optional
|
||||
Short-period lookback, by default 7.
|
||||
medium : PositiveInt, optional
|
||||
Medium-period lookback, by default 14.
|
||||
slow : PositiveInt, optional
|
||||
Long-period lookback, by default 28.
|
||||
fast_weight : PositiveFloat, optional
|
||||
Weight applied to the fast period, by default 4.0.
|
||||
medium_weight : PositiveFloat, optional
|
||||
Weight applied to the medium period, by default 2.0.
|
||||
slow_weight : PositiveFloat, optional
|
||||
Weight applied to the slow period, by default 1.0.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "ultimate_oscillator")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
fast: PositiveInt = Field(default=7, description="Short-period lookback.")
|
||||
medium: PositiveInt = Field(default=14, description="Medium-period lookback.")
|
||||
slow: PositiveInt = Field(default=28, description="Long-period lookback.")
|
||||
fast_weight: PositiveFloat = Field(
|
||||
default=4.0, description="Weight applied to the fast period."
|
||||
)
|
||||
medium_weight: PositiveFloat = Field(
|
||||
default=2.0, description="Weight applied to the medium period."
|
||||
)
|
||||
slow_weight: PositiveFloat = Field(
|
||||
default=1.0, description="Weight applied to the slow period."
|
||||
)
|
||||
|
||||
|
||||
class UltimateOscillatorData(Data):
|
||||
"""One row of the Ultimate Oscillator output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
ultimate_oscillator : float, optional
|
||||
Weighted blend of buying pressure over the fast, medium, and slow
|
||||
timeframes, scaled to the 0–100 range.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
ultimate_oscillator: float | None = Field(
|
||||
description="Weighted blend of buying pressure over three timeframes (0–100).",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Ultimate Oscillator on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"uo = obb.technical.ultimate_oscillator(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def ultimate_oscillator(
|
||||
params: UltimateOscillatorQueryParams,
|
||||
) -> OBBject[list[UltimateOscillatorData]]:
|
||||
"""Calculate Larry Williams' Ultimate Oscillator across three timeframes."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.fast, params.medium, params.slow])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.uo(
|
||||
fast=params.fast,
|
||||
medium=params.medium,
|
||||
slow=params.slow,
|
||||
fast_w=params.fast_weight,
|
||||
medium_w=params.medium_weight,
|
||||
slow_w=params.slow_weight,
|
||||
)
|
||||
out = (
|
||||
pd.DataFrame({"ultimate_oscillator": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(
|
||||
results=[UltimateOscillatorData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
class AwesomeOscillatorQueryParams(QueryParams):
|
||||
"""Query parameters for Bill Williams' Awesome Oscillator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Price series. Uses the median of high and low on each bar.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast : PositiveInt, optional
|
||||
Short SMA window on median price, by default 5.
|
||||
slow : PositiveInt, optional
|
||||
Long SMA window on median price, by default 34.
|
||||
"""
|
||||
|
||||
__category__ = "oscillator"
|
||||
__output_columns__ = ("date", "awesome_oscillator")
|
||||
|
||||
data: list[Data] = Field(description="Price series — uses median of high/low.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
fast: PositiveInt = Field(
|
||||
default=5, description="Short SMA window on median price."
|
||||
)
|
||||
slow: PositiveInt = Field(
|
||||
default=34, description="Long SMA window on median price."
|
||||
)
|
||||
|
||||
|
||||
class AwesomeOscillatorData(Data):
|
||||
"""One row of the Awesome Oscillator output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
awesome_oscillator : float, optional
|
||||
Fast SMA minus slow SMA of median price.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
awesome_oscillator: float | None = Field(
|
||||
description="Fast SMA minus slow SMA of median price.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Awesome Oscillator on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"ao = obb.technical.awesome_oscillator(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def awesome_oscillator(
|
||||
params: AwesomeOscillatorQueryParams,
|
||||
) -> OBBject[list[AwesomeOscillatorData]]:
|
||||
"""Calculate Bill Williams' Awesome Oscillator as a momentum histogram."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.fast, params.slow])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.ao(fast=params.fast, slow=params.slow)
|
||||
out = (
|
||||
pd.DataFrame({"awesome_oscillator": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(
|
||||
results=[AwesomeOscillatorData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AwesomeOscillatorData",
|
||||
"AwesomeOscillatorQueryParams",
|
||||
"CciData",
|
||||
"CciQueryParams",
|
||||
"CgData",
|
||||
"CgQueryParams",
|
||||
"FisherData",
|
||||
"FisherQueryParams",
|
||||
"MfiData",
|
||||
"MfiQueryParams",
|
||||
"RsiData",
|
||||
"RsiQueryParams",
|
||||
"StochData",
|
||||
"StochQueryParams",
|
||||
"TrixData",
|
||||
"TrixQueryParams",
|
||||
"UltimateOscillatorData",
|
||||
"UltimateOscillatorQueryParams",
|
||||
"WilliamsRData",
|
||||
"WilliamsRQueryParams",
|
||||
"awesome_oscillator",
|
||||
"cci",
|
||||
"cg",
|
||||
"fisher",
|
||||
"mfi",
|
||||
"router",
|
||||
"rsi",
|
||||
"stoch",
|
||||
"trix",
|
||||
"ultimate_oscillator",
|
||||
"williams_r",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
"""Relative-rotation router module."""
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.relative_rotation import (
|
||||
RelativeRotationData,
|
||||
RelativeRotationFetcher,
|
||||
RelativeRotationQueryParams,
|
||||
)
|
||||
|
||||
router = Router(prefix="", description="Relative rotation indicator.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Relative Strength Ratio and Momentum for a basket of symbols against a benchmark.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='AAPL,MSFT,GOOGL,META,AMZN,TSLA,SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"rr = obb.technical.relative_rotation(data=data, benchmark='SPY')",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "benchmark": "SPY"}),
|
||||
],
|
||||
)
|
||||
async def relative_rotation(
|
||||
params: RelativeRotationQueryParams,
|
||||
) -> OBBject[RelativeRotationData]:
|
||||
"""Compute the Relative Rotation Graph (RRG) for a basket against a benchmark."""
|
||||
return OBBject(
|
||||
results=RelativeRotationFetcher.transform_data(
|
||||
params, RelativeRotationFetcher.extract_data(params, {})
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RelativeRotationQueryParams", "relative_rotation", "router"]
|
||||
@@ -0,0 +1,976 @@
|
||||
"""Statistics-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df, get_target_column
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import clenow_momentum, validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Statistics indicators.")
|
||||
|
||||
|
||||
_FREQ_FACTORS: dict[str, int] = {
|
||||
"daily": 252,
|
||||
"weekly": 52,
|
||||
"monthly": 12,
|
||||
"quarterly": 4,
|
||||
"annual": 1,
|
||||
}
|
||||
|
||||
Frequency = Literal["daily", "weekly", "monthly", "quarterly", "annual"]
|
||||
|
||||
|
||||
class ClenowQueryParams(QueryParams):
|
||||
"""Query parameters for the Clenow Volatility-Adjusted Momentum endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to regress on the log-linear time index, by default ``"close"``.
|
||||
period : PositiveInt, optional
|
||||
Lookback window in bars for the log-price regression, by default 90.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"predicted",
|
||||
"r2",
|
||||
"coefficient",
|
||||
"annualized_coefficient",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to regress.")
|
||||
period: PositiveInt = Field(
|
||||
default=90,
|
||||
description="Lookback window for the log-price regression.",
|
||||
)
|
||||
|
||||
|
||||
class ClenowData(Data):
|
||||
"""One row of the Clenow best-fit regression with the summary echoed per row.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
predicted : float, optional
|
||||
Best-fit log-linear value at this date.
|
||||
r2 : float
|
||||
R-squared of the regression fit on log prices.
|
||||
coefficient : float
|
||||
Annualised slope of the log-price regression.
|
||||
annualized_coefficient : float
|
||||
``coefficient * r2`` - Clenow's volatility-adjusted momentum factor.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
predicted: float | None = Field(
|
||||
description="Best-fit log-linear value at this date."
|
||||
)
|
||||
r2: float = Field(description="R-squared of the regression fit on log prices.")
|
||||
coefficient: float = Field(
|
||||
description="Annualised slope of the log-price regression."
|
||||
)
|
||||
annualized_coefficient: float = Field(
|
||||
description="``coefficient * r2`` - Clenow's momentum factor.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Clenow momentum on TSLA over the trailing 90 days.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='yfinance').results",
|
||||
"out = obb.technical.clenow(data=data, period=90)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "period": 2}),
|
||||
],
|
||||
)
|
||||
def clenow(params: ClenowQueryParams) -> OBBject[list[ClenowData]]:
|
||||
"""Calculate Clenow Volatility-Adjusted Momentum on log prices."""
|
||||
validate_data(params.data, params.period)
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = get_target_column(df, params.target)
|
||||
r2, coef, predictions = clenow_momentum(series, params.period)
|
||||
factor = coef * r2
|
||||
tail = df.tail(params.period).reset_index().rename(columns={params.index: "date"})
|
||||
out = []
|
||||
for i, row in enumerate(tail.to_dict(orient="records")):
|
||||
out.append(
|
||||
ClenowData(
|
||||
date=row["date"],
|
||||
predicted=float(predictions.iloc[i]),
|
||||
r2=float(r2),
|
||||
coefficient=float(coef),
|
||||
annualized_coefficient=float(factor),
|
||||
)
|
||||
)
|
||||
return OBBject(results=out)
|
||||
|
||||
|
||||
class DrawdownQueryParams(QueryParams):
|
||||
"""Query parameters for the drawdown endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to evaluate drawdowns on, by default ``"close"``.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"cumulative_return",
|
||||
"running_peak",
|
||||
"drawdown",
|
||||
"drawdown_duration_days",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to evaluate drawdowns on.")
|
||||
|
||||
|
||||
class DrawdownData(Data):
|
||||
"""One row of the drawdown time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
cumulative_return : float, optional
|
||||
Cumulative return from the first observation in ``data``.
|
||||
running_peak : float, optional
|
||||
Running maximum of ``cumulative_return``.
|
||||
drawdown : float, optional
|
||||
Percentage drop from the running peak (negative or zero).
|
||||
drawdown_duration_days : int
|
||||
Consecutive observations since the most recent peak.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
cumulative_return: float | None = Field(
|
||||
description="Cumulative return from the first observation.",
|
||||
)
|
||||
running_peak: float | None = Field(
|
||||
description="Running maximum of ``cumulative_return``."
|
||||
)
|
||||
drawdown: float | None = Field(
|
||||
description="Percentage drop from the running peak (negative or zero).",
|
||||
)
|
||||
drawdown_duration_days: int = Field(
|
||||
description="Consecutive observations since the most recent peak.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def drawdown(params: DrawdownQueryParams) -> OBBject[list[DrawdownData]]:
|
||||
"""Compute cumulative return, running peak, drawdown, and underwater duration."""
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = get_target_column(df, params.target).astype(float)
|
||||
cum = series / series.iloc[0] - 1.0
|
||||
peak = cum.cummax()
|
||||
dd = (1.0 + cum) / (1.0 + peak) - 1.0
|
||||
duration = []
|
||||
counter = 0
|
||||
for c, p in zip(cum.tolist(), peak.tolist()):
|
||||
if c >= p:
|
||||
counter = 0
|
||||
else:
|
||||
counter += 1
|
||||
duration.append(counter)
|
||||
out = []
|
||||
for i, idx in enumerate(cum.index):
|
||||
out.append(
|
||||
DrawdownData(
|
||||
date=idx,
|
||||
cumulative_return=float(cum.iloc[i]),
|
||||
running_peak=float(peak.iloc[i]),
|
||||
drawdown=float(dd.iloc[i]),
|
||||
drawdown_duration_days=int(duration[i]),
|
||||
)
|
||||
)
|
||||
return OBBject(results=out)
|
||||
|
||||
|
||||
class ReturnsStatsQueryParams(QueryParams):
|
||||
"""Query parameters for the ``returns_stats`` endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to evaluate returns on, by default ``"close"``.
|
||||
frequency : Frequency, optional
|
||||
Annualisation frequency for Sharpe, Sortino, Calmar, and the
|
||||
per-period risk-free rate, by default ``"daily"``.
|
||||
risk_free_rate : float, optional
|
||||
Annualised risk-free rate used for Sharpe and Sortino numerators,
|
||||
by default 0.0.
|
||||
window : PositiveInt, optional
|
||||
Rolling window length in bars. ``None`` returns a single summary row
|
||||
over the full sample, by default ``None``.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"mean_return",
|
||||
"std_return",
|
||||
"skew",
|
||||
"kurtosis",
|
||||
"sharpe",
|
||||
"sortino",
|
||||
"calmar",
|
||||
"max_drawdown",
|
||||
"var_95",
|
||||
"cvar_95",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to evaluate returns on.")
|
||||
frequency: Frequency = Field(
|
||||
default="daily",
|
||||
description="Annualisation frequency for Sharpe/Sortino/Calmar/risk-free.",
|
||||
)
|
||||
risk_free_rate: float = Field(
|
||||
default=0.0,
|
||||
description="Annualised risk-free rate used for Sharpe and Sortino.",
|
||||
)
|
||||
window: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description="Rolling window. ``None`` returns a single summary row.",
|
||||
)
|
||||
|
||||
|
||||
class ReturnsStatsData(Data):
|
||||
"""One row of return-distribution statistics.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str, optional
|
||||
Window end date, or ``None`` for the summary row when ``window`` is
|
||||
unset.
|
||||
mean_return : float, optional
|
||||
Arithmetic mean of simple returns over the window.
|
||||
std_return : float, optional
|
||||
Sample standard deviation of returns (ddof=1).
|
||||
skew : float, optional
|
||||
Skewness of the return distribution.
|
||||
kurtosis : float, optional
|
||||
Excess kurtosis of the return distribution.
|
||||
sharpe : float, optional
|
||||
Annualised Sharpe ratio of excess returns.
|
||||
sortino : float, optional
|
||||
Annualised Sortino ratio using downside deviation.
|
||||
calmar : float, optional
|
||||
Annualised return divided by the absolute value of max drawdown.
|
||||
max_drawdown : float, optional
|
||||
Worst peak-to-trough drawdown over the window (negative).
|
||||
var_95 : float, optional
|
||||
5th-percentile return - historical Value-at-Risk at 95%.
|
||||
cvar_95 : float, optional
|
||||
Mean of returns at or below ``var_95`` - historical Conditional VaR.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str | None = Field(
|
||||
default=None, description="Window end date, or ``None`` for the summary row."
|
||||
)
|
||||
mean_return: float | None = Field(
|
||||
description="Arithmetic mean of simple returns over the window."
|
||||
)
|
||||
std_return: float | None = Field(
|
||||
description="Sample standard deviation of returns (ddof=1)."
|
||||
)
|
||||
skew: float | None = Field(description="Skewness of the return distribution.")
|
||||
kurtosis: float | None = Field(
|
||||
description="Excess kurtosis of the return distribution."
|
||||
)
|
||||
sharpe: float | None = Field(
|
||||
description="Annualised Sharpe ratio of excess returns."
|
||||
)
|
||||
sortino: float | None = Field(
|
||||
description="Annualised Sortino ratio using downside deviation."
|
||||
)
|
||||
calmar: float | None = Field(
|
||||
description="Annualised return divided by the absolute value of max drawdown."
|
||||
)
|
||||
max_drawdown: float | None = Field(
|
||||
description="Worst peak-to-trough drawdown over the window (negative)."
|
||||
)
|
||||
var_95: float | None = Field(
|
||||
description="5th-percentile return - historical Value-at-Risk at 95%."
|
||||
)
|
||||
cvar_95: float | None = Field(
|
||||
description="Mean of returns at or below ``var_95`` - historical Conditional VaR."
|
||||
)
|
||||
|
||||
|
||||
def _stats_block(returns, periods_per_year: int, rf_per_period: float) -> dict:
|
||||
"""Compute the full statistics block for one return vector."""
|
||||
from numpy import isfinite, sqrt
|
||||
from scipy.stats import (
|
||||
kurtosis as _kurt,
|
||||
skew as _skew,
|
||||
)
|
||||
|
||||
clean = returns.dropna()
|
||||
if len(clean) < 2:
|
||||
return {
|
||||
"mean_return": None,
|
||||
"std_return": None,
|
||||
"skew": None,
|
||||
"kurtosis": None,
|
||||
"sharpe": None,
|
||||
"sortino": None,
|
||||
"calmar": None,
|
||||
"max_drawdown": None,
|
||||
"var_95": None,
|
||||
"cvar_95": None,
|
||||
}
|
||||
mean = float(clean.mean())
|
||||
std = float(clean.std(ddof=1))
|
||||
sk = float(_skew(clean))
|
||||
kt = float(_kurt(clean))
|
||||
excess = clean - rf_per_period
|
||||
sharpe = (
|
||||
float(excess.mean() / excess.std(ddof=1) * sqrt(periods_per_year))
|
||||
if excess.std(ddof=1) > 0
|
||||
else None
|
||||
)
|
||||
downside = excess[excess < 0]
|
||||
if len(downside) > 0 and downside.std(ddof=1) > 0:
|
||||
sortino = float(excess.mean() / downside.std(ddof=1) * sqrt(periods_per_year))
|
||||
else:
|
||||
sortino = None
|
||||
cum = (1.0 + clean).cumprod()
|
||||
peak = cum.cummax()
|
||||
dd = cum / peak - 1.0
|
||||
max_dd = float(dd.min())
|
||||
ann_return = (float(cum.iloc[-1]) ** (periods_per_year / len(clean))) - 1.0
|
||||
calmar = float(ann_return / abs(max_dd)) if max_dd < 0 else None
|
||||
var_95 = float(clean.quantile(0.05))
|
||||
tail = clean[clean <= var_95]
|
||||
cvar_95 = float(tail.mean()) if len(tail) > 0 else None
|
||||
return {
|
||||
"mean_return": mean,
|
||||
"std_return": std,
|
||||
"skew": sk if isfinite(sk) else None,
|
||||
"kurtosis": kt if isfinite(kt) else None,
|
||||
"sharpe": sharpe,
|
||||
"sortino": sortino,
|
||||
"calmar": calmar,
|
||||
"max_drawdown": max_dd,
|
||||
"var_95": var_95,
|
||||
"cvar_95": cvar_95,
|
||||
}
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def returns_stats(params: ReturnsStatsQueryParams) -> OBBject[list[ReturnsStatsData]]:
|
||||
"""Calculate distributional and risk-adjusted statistics on simple returns."""
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = get_target_column(df, params.target).astype(float)
|
||||
returns = series.pct_change().dropna()
|
||||
periods_per_year = _FREQ_FACTORS[params.frequency]
|
||||
rf_per_period = params.risk_free_rate / periods_per_year
|
||||
if params.window is None:
|
||||
block = _stats_block(returns, periods_per_year, rf_per_period)
|
||||
block["date"] = returns.index[-1] if len(returns) else None
|
||||
return OBBject(results=[ReturnsStatsData(**block)])
|
||||
out: list[ReturnsStatsData] = []
|
||||
for end in range(params.window, len(returns) + 1):
|
||||
chunk = returns.iloc[end - params.window : end]
|
||||
block = _stats_block(chunk, periods_per_year, rf_per_period)
|
||||
block["date"] = chunk.index[-1]
|
||||
out.append(ReturnsStatsData(**block))
|
||||
return OBBject(results=out)
|
||||
|
||||
|
||||
class StationarityQueryParams(QueryParams):
|
||||
"""Query parameters for the stationarity endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to test for stationarity, by default ``"close"``.
|
||||
test : {"adf", "kpss", "both"}, optional
|
||||
Which test(s) to run, by default ``"both"``.
|
||||
regression : {"c", "ct", "ctt", "n"}, optional
|
||||
Deterministic component included in the regression: ``c`` constant,
|
||||
``ct`` constant plus trend, ``ctt`` constant plus quadratic trend,
|
||||
``n`` none. KPSS supports ``c`` or ``ct`` only and falls back to
|
||||
``c`` for other values, by default ``"c"``.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = (
|
||||
"adf_statistic",
|
||||
"adf_pvalue",
|
||||
"adf_critical_1pct",
|
||||
"adf_critical_5pct",
|
||||
"adf_critical_10pct",
|
||||
"adf_verdict",
|
||||
"kpss_statistic",
|
||||
"kpss_pvalue",
|
||||
"kpss_critical_1pct",
|
||||
"kpss_critical_5pct",
|
||||
"kpss_critical_10pct",
|
||||
"kpss_verdict",
|
||||
"overall_verdict",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to test for stationarity.")
|
||||
test: Literal["adf", "kpss", "both"] = Field(
|
||||
default="both", description="Which test(s) to run."
|
||||
)
|
||||
regression: Literal["c", "ct", "ctt", "n"] = Field(
|
||||
default="c",
|
||||
description="Regression component: constant, trend, quadratic-trend, or none.",
|
||||
)
|
||||
|
||||
|
||||
class StationarityData(Data):
|
||||
"""Single-row stationarity-test summary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
adf_statistic : float, optional
|
||||
Augmented Dickey-Fuller test statistic.
|
||||
adf_pvalue : float, optional
|
||||
ADF p-value.
|
||||
adf_critical_1pct : float, optional
|
||||
ADF critical value at the 1% significance level.
|
||||
adf_critical_5pct : float, optional
|
||||
ADF critical value at the 5% significance level.
|
||||
adf_critical_10pct : float, optional
|
||||
ADF critical value at the 10% significance level.
|
||||
adf_verdict : {"stationary", "non_stationary", "skipped"}, optional
|
||||
ADF verdict at 5%, or ``"skipped"`` when the test was not run.
|
||||
kpss_statistic : float, optional
|
||||
Kwiatkowski-Phillips-Schmidt-Shin test statistic.
|
||||
kpss_pvalue : float, optional
|
||||
KPSS p-value.
|
||||
kpss_critical_1pct : float, optional
|
||||
KPSS critical value at the 1% significance level.
|
||||
kpss_critical_5pct : float, optional
|
||||
KPSS critical value at the 5% significance level.
|
||||
kpss_critical_10pct : float, optional
|
||||
KPSS critical value at the 10% significance level.
|
||||
kpss_verdict : {"stationary", "non_stationary", "skipped"}, optional
|
||||
KPSS verdict at 5%, or ``"skipped"`` when the test was not run.
|
||||
overall_verdict : {"stationary", "non_stationary", "trend_stationary", "inconclusive"}
|
||||
Combined verdict from the available ADF and KPSS results.
|
||||
"""
|
||||
|
||||
adf_statistic: float | None = Field(
|
||||
description="Augmented Dickey-Fuller test statistic."
|
||||
)
|
||||
adf_pvalue: float | None = Field(description="ADF p-value.")
|
||||
adf_critical_1pct: float | None = Field(
|
||||
description="ADF critical value at the 1% significance level."
|
||||
)
|
||||
adf_critical_5pct: float | None = Field(
|
||||
description="ADF critical value at the 5% significance level."
|
||||
)
|
||||
adf_critical_10pct: float | None = Field(
|
||||
description="ADF critical value at the 10% significance level."
|
||||
)
|
||||
adf_verdict: Literal["stationary", "non_stationary", "skipped"] | None = Field(
|
||||
description='ADF verdict at 5%, or ``"skipped"`` when the test was not run.'
|
||||
)
|
||||
kpss_statistic: float | None = Field(
|
||||
description="Kwiatkowski-Phillips-Schmidt-Shin test statistic."
|
||||
)
|
||||
kpss_pvalue: float | None = Field(description="KPSS p-value.")
|
||||
kpss_critical_1pct: float | None = Field(
|
||||
description="KPSS critical value at the 1% significance level."
|
||||
)
|
||||
kpss_critical_5pct: float | None = Field(
|
||||
description="KPSS critical value at the 5% significance level."
|
||||
)
|
||||
kpss_critical_10pct: float | None = Field(
|
||||
description="KPSS critical value at the 10% significance level."
|
||||
)
|
||||
kpss_verdict: Literal["stationary", "non_stationary", "skipped"] | None = Field(
|
||||
description='KPSS verdict at 5%, or ``"skipped"`` when the test was not run.'
|
||||
)
|
||||
overall_verdict: Literal[
|
||||
"stationary", "non_stationary", "trend_stationary", "inconclusive"
|
||||
] = Field(description="Combined verdict from the available ADF and KPSS results.")
|
||||
|
||||
|
||||
def _adf_block(values, regression: str) -> dict:
|
||||
"""Run ADF and package the result."""
|
||||
from statsmodels.tsa.stattools import adfuller
|
||||
|
||||
result = adfuller(values, regression=regression)
|
||||
stat, pvalue, _, _, crit, _ = result
|
||||
verdict = "stationary" if pvalue < 0.05 else "non_stationary"
|
||||
return {
|
||||
"adf_statistic": float(stat),
|
||||
"adf_pvalue": float(pvalue),
|
||||
"adf_critical_1pct": float(crit.get("1%")),
|
||||
"adf_critical_5pct": float(crit.get("5%")),
|
||||
"adf_critical_10pct": float(crit.get("10%")),
|
||||
"adf_verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def _kpss_block(values, regression: str) -> dict:
|
||||
"""Run KPSS and package the result. KPSS supports ``c`` or ``ct`` only."""
|
||||
import warnings
|
||||
|
||||
from statsmodels.tsa.stattools import kpss
|
||||
|
||||
kpss_reg: Literal["c", "ct"] = "c" if regression not in {"c", "ct"} else regression # ty: ignore[invalid-assignment]
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
stat, pvalue, _, crit = kpss(values, regression=kpss_reg, nlags="auto")
|
||||
verdict = "non_stationary" if pvalue < 0.05 else "stationary"
|
||||
crit_map: dict[str, float] = dict(crit)
|
||||
return {
|
||||
"kpss_statistic": float(stat),
|
||||
"kpss_pvalue": float(pvalue),
|
||||
"kpss_critical_1pct": crit_map.get("1%", float("nan")),
|
||||
"kpss_critical_5pct": crit_map.get("5%", float("nan")),
|
||||
"kpss_critical_10pct": crit_map.get("10%", float("nan")),
|
||||
"kpss_verdict": verdict,
|
||||
}
|
||||
|
||||
|
||||
def _overall_verdict(adf, kpss_, regression: str) -> str:
|
||||
"""Combine ADF + KPSS verdicts into an overall verdict."""
|
||||
if adf is None and kpss_ is None:
|
||||
return "inconclusive" # pragma: no cover - guarded by the caller
|
||||
if adf is not None and kpss_ is None:
|
||||
return "stationary" if adf == "stationary" else "non_stationary"
|
||||
if kpss_ is not None and adf is None:
|
||||
return "stationary" if kpss_ == "stationary" else "non_stationary"
|
||||
if adf == "stationary" and kpss_ == "stationary":
|
||||
return "trend_stationary" if regression in {"ct", "ctt"} else "stationary"
|
||||
if adf == "non_stationary" and kpss_ == "non_stationary":
|
||||
return "non_stationary"
|
||||
return "inconclusive"
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def stationarity(params: StationarityQueryParams) -> OBBject[list[StationarityData]]:
|
||||
"""Test a series for stationarity using ADF, KPSS, or both with a combined verdict."""
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
values = get_target_column(df, params.target).astype(float).dropna().to_numpy()
|
||||
blank = {
|
||||
"adf_statistic": None,
|
||||
"adf_pvalue": None,
|
||||
"adf_critical_1pct": None,
|
||||
"adf_critical_5pct": None,
|
||||
"adf_critical_10pct": None,
|
||||
"adf_verdict": None,
|
||||
"kpss_statistic": None,
|
||||
"kpss_pvalue": None,
|
||||
"kpss_critical_1pct": None,
|
||||
"kpss_critical_5pct": None,
|
||||
"kpss_critical_10pct": None,
|
||||
"kpss_verdict": None,
|
||||
}
|
||||
payload = dict(blank)
|
||||
if params.test in {"adf", "both"}:
|
||||
payload.update(_adf_block(values, params.regression))
|
||||
else:
|
||||
payload["adf_verdict"] = "skipped"
|
||||
if params.test in {"kpss", "both"}:
|
||||
payload.update(_kpss_block(values, params.regression))
|
||||
else:
|
||||
payload["kpss_verdict"] = "skipped"
|
||||
adf_v = (
|
||||
payload["adf_verdict"]
|
||||
if payload["adf_verdict"] not in {None, "skipped"}
|
||||
else None
|
||||
)
|
||||
kpss_v = (
|
||||
payload["kpss_verdict"]
|
||||
if payload["kpss_verdict"] not in {None, "skipped"}
|
||||
else None
|
||||
)
|
||||
payload["overall_verdict"] = _overall_verdict(adf_v, kpss_v, params.regression)
|
||||
return OBBject(results=[StationarityData(**payload)]) # ty: ignore[invalid-argument-type]
|
||||
|
||||
|
||||
class HurstQueryParams(QueryParams):
|
||||
"""Query parameters for the Hurst-exponent endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to evaluate, by default ``"close"``.
|
||||
method : {"rs", "dfa"}, optional
|
||||
Estimation method: ``rs`` for classical Rescaled-Range analysis,
|
||||
``dfa`` for Detrended Fluctuation Analysis, by default ``"rs"``.
|
||||
min_lag : PositiveInt, optional
|
||||
Smallest window length used in the log-log fit, by default 2.
|
||||
max_lag : PositiveInt, optional
|
||||
Largest window length used in the log-log fit (exclusive),
|
||||
by default 100.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = ("hurst_exponent", "interpretation", "confidence")
|
||||
|
||||
data: list[Data] = Field(description="Input series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to evaluate.")
|
||||
method: Literal["rs", "dfa"] = Field(
|
||||
default="rs",
|
||||
description="``rs`` = Rescaled Range; ``dfa`` = Detrended Fluctuation.",
|
||||
)
|
||||
min_lag: PositiveInt = Field(default=2, description="Smallest window length.")
|
||||
max_lag: PositiveInt = Field(
|
||||
default=100, description="Largest window length (exclusive)."
|
||||
)
|
||||
|
||||
|
||||
class HurstData(Data):
|
||||
"""Single-row Hurst-exponent summary.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hurst_exponent : float, optional
|
||||
Estimated Hurst exponent in ``[0, 1]``. ``None`` when the fit could
|
||||
not be computed.
|
||||
interpretation : {"trending", "mean_reverting", "random_walk"}
|
||||
Qualitative classification: ``trending`` for H > 0.55,
|
||||
``mean_reverting`` for H < 0.45, otherwise ``random_walk``.
|
||||
confidence : float, optional
|
||||
R-squared of the log-log fit used to estimate the exponent.
|
||||
"""
|
||||
|
||||
hurst_exponent: float | None = Field(
|
||||
description="Estimated Hurst exponent in ``[0, 1]``. ``None`` when the fit could not be computed."
|
||||
)
|
||||
interpretation: Literal["trending", "mean_reverting", "random_walk"] = Field(
|
||||
description="Qualitative classification: ``trending`` for H > 0.55, ``mean_reverting`` for H < 0.45, otherwise ``random_walk``."
|
||||
)
|
||||
confidence: float | None = Field(
|
||||
description="R-squared of the log-log fit used to estimate the exponent.",
|
||||
)
|
||||
|
||||
|
||||
def _hurst_rs(values, min_lag: int, max_lag: int) -> tuple[float, float]:
|
||||
"""Hurst via classical R/S analysis."""
|
||||
from numpy import array, log, mean, polyfit
|
||||
|
||||
lags: list[int] = []
|
||||
rs_values: list[float] = []
|
||||
for lag in range(min_lag, max_lag):
|
||||
chunks = len(values) // lag
|
||||
if chunks < 1:
|
||||
continue
|
||||
rs_chunk: list[float] = []
|
||||
for i in range(chunks):
|
||||
block = values[i * lag : (i + 1) * lag]
|
||||
block_mean = block.mean()
|
||||
dev = block - block_mean
|
||||
cum = dev.cumsum()
|
||||
r = float(cum.max() - cum.min())
|
||||
s = float(block.std(ddof=0))
|
||||
if s > 0:
|
||||
rs_chunk.append(r / s)
|
||||
if rs_chunk:
|
||||
lags.append(lag)
|
||||
rs_values.append(float(mean(rs_chunk)))
|
||||
if len(lags) < 2:
|
||||
return float("nan"), float("nan")
|
||||
log_lags = log(array(lags, dtype="float64"))
|
||||
log_rs = log(array(rs_values, dtype="float64"))
|
||||
slope, intercept = polyfit(log_lags, log_rs, 1)
|
||||
fitted = slope * log_lags + intercept
|
||||
ss_res = float(((log_rs - fitted) ** 2).sum())
|
||||
ss_tot = float(((log_rs - log_rs.mean()) ** 2).sum())
|
||||
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
|
||||
return float(slope), float(r2)
|
||||
|
||||
|
||||
def _hurst_dfa(values, min_lag: int, max_lag: int) -> tuple[float, float]:
|
||||
"""Hurst via Detrended Fluctuation Analysis."""
|
||||
from numpy import arange, array, log, mean, polyfit, polyval, sqrt
|
||||
|
||||
y = (values - values.mean()).cumsum()
|
||||
lags: list[int] = []
|
||||
fluct: list[float] = []
|
||||
for n_box in range(max(min_lag, 4), max_lag):
|
||||
n_segs = len(y) // n_box
|
||||
if n_segs < 2:
|
||||
continue
|
||||
rms: list[float] = []
|
||||
t = arange(n_box)
|
||||
for i in range(n_segs):
|
||||
seg = y[i * n_box : (i + 1) * n_box]
|
||||
coeffs = polyfit(t, seg, 1)
|
||||
trend = polyval(coeffs, t)
|
||||
rms.append(float(sqrt(mean((seg - trend) ** 2))))
|
||||
if rms:
|
||||
lags.append(n_box)
|
||||
fluct.append(float(mean(rms)))
|
||||
if len(lags) < 2:
|
||||
return float("nan"), float("nan")
|
||||
log_lags = log(array(lags, dtype="float64"))
|
||||
log_f = log(array(fluct, dtype="float64"))
|
||||
slope, intercept = polyfit(log_lags, log_f, 1)
|
||||
fitted = slope * log_lags + intercept
|
||||
ss_res = float(((log_f - fitted) ** 2).sum())
|
||||
ss_tot = float(((log_f - log_f.mean()) ** 2).sum())
|
||||
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else float("nan")
|
||||
return float(slope), float(r2)
|
||||
|
||||
|
||||
def _interpret_hurst(h: float) -> str:
|
||||
"""Classify a Hurst exponent value."""
|
||||
import math
|
||||
|
||||
if h is None or math.isnan(h):
|
||||
return "random_walk"
|
||||
if h > 0.55:
|
||||
return "trending"
|
||||
if h < 0.45:
|
||||
return "mean_reverting"
|
||||
return "random_walk"
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def hurst(params: HurstQueryParams) -> OBBject[list[HurstData]]:
|
||||
"""Estimate the Hurst exponent via Rescaled-Range analysis or DFA."""
|
||||
if params.max_lag <= params.min_lag:
|
||||
raise ValueError("max_lag must be greater than min_lag.")
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
values = get_target_column(df, params.target).astype(float).dropna().to_numpy()
|
||||
if params.method == "rs":
|
||||
h, r2 = _hurst_rs(values, params.min_lag, params.max_lag)
|
||||
else:
|
||||
h, r2 = _hurst_dfa(values, params.min_lag, params.max_lag)
|
||||
import math
|
||||
|
||||
h_out = None if math.isnan(h) else float(h)
|
||||
r2_out = None if math.isnan(r2) else float(r2)
|
||||
return OBBject(
|
||||
results=[
|
||||
HurstData(
|
||||
hurst_exponent=h_out,
|
||||
interpretation=_interpret_hurst(h), # ty: ignore[invalid-argument-type]
|
||||
confidence=r2_out,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AutocorrelationQueryParams(QueryParams):
|
||||
"""Query parameters for the autocorrelation endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to evaluate, by default ``"close"``.
|
||||
use_returns : bool, optional
|
||||
When ``True``, percent-change the series before computing ACF/PACF -
|
||||
the standard choice for prices since raw prices are typically
|
||||
non-stationary, by default ``True``.
|
||||
max_lag : PositiveInt, optional
|
||||
Largest lag to compute, by default 40.
|
||||
method : {"acf", "pacf", "both"}, optional
|
||||
Which functions to compute, by default ``"both"``.
|
||||
"""
|
||||
|
||||
__category__ = "stats"
|
||||
__output_columns__ = (
|
||||
"lag",
|
||||
"acf",
|
||||
"pacf",
|
||||
"acf_confidence_lower",
|
||||
"acf_confidence_upper",
|
||||
"significant",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to evaluate.")
|
||||
use_returns: bool = Field(
|
||||
default=True, description="Difference and percent-change before computing."
|
||||
)
|
||||
max_lag: PositiveInt = Field(default=40, description="Largest lag to compute.")
|
||||
method: Literal["acf", "pacf", "both"] = Field(
|
||||
default="both", description="Which functions to compute."
|
||||
)
|
||||
|
||||
|
||||
class AutocorrelationData(Data):
|
||||
"""One row per lag of the autocorrelation analysis.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
lag : int
|
||||
Lag in bars (0 is the series with itself, by definition 1.0 for ACF).
|
||||
acf : float, optional
|
||||
Sample autocorrelation at this lag, or ``None`` when ACF was skipped.
|
||||
pacf : float, optional
|
||||
Sample partial autocorrelation at this lag, or ``None`` when PACF
|
||||
was skipped.
|
||||
acf_confidence_lower : float, optional
|
||||
Lower bound of the 95% confidence band for the ACF estimate.
|
||||
acf_confidence_upper : float, optional
|
||||
Upper bound of the 95% confidence band for the ACF estimate.
|
||||
significant : bool
|
||||
``True`` when the ACF estimate at this lag (other than lag 0) lies
|
||||
outside the 95% confidence band.
|
||||
"""
|
||||
|
||||
lag: int = Field(
|
||||
description="Lag in bars (0 is the series with itself, by definition 1.0 for ACF)."
|
||||
)
|
||||
acf: float | None = Field(
|
||||
description="Sample autocorrelation at this lag, or ``None`` when ACF was skipped."
|
||||
)
|
||||
pacf: float | None = Field(
|
||||
description="Sample partial autocorrelation at this lag, or ``None`` when PACF was skipped."
|
||||
)
|
||||
acf_confidence_lower: float | None = Field(
|
||||
description="Lower bound of the 95% confidence band for the ACF estimate."
|
||||
)
|
||||
acf_confidence_upper: float | None = Field(
|
||||
description="Upper bound of the 95% confidence band for the ACF estimate."
|
||||
)
|
||||
significant: bool = Field(
|
||||
description="``True`` when the ACF estimate at this lag (other than lag 0) lies outside the 95% confidence band."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def autocorrelation(
|
||||
params: AutocorrelationQueryParams,
|
||||
) -> OBBject[list[AutocorrelationData]]:
|
||||
"""Compute autocorrelation (ACF) and partial autocorrelation (PACF) with bands."""
|
||||
from statsmodels.tsa.stattools import (
|
||||
acf as _acf,
|
||||
pacf as _pacf,
|
||||
)
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = get_target_column(df, params.target).astype(float)
|
||||
series = series.pct_change().dropna() if params.use_returns else series.dropna()
|
||||
if len(series) <= params.max_lag + 1:
|
||||
raise ValueError("Series too short for the requested max_lag.")
|
||||
acf_vals = None
|
||||
acf_lower = None
|
||||
acf_upper = None
|
||||
if params.method in {"acf", "both"}:
|
||||
acf_vals, conf = _acf(series.to_numpy(), nlags=params.max_lag, alpha=0.05)
|
||||
acf_lower = conf[:, 0] - acf_vals
|
||||
acf_upper = conf[:, 1] - acf_vals
|
||||
pacf_vals = None
|
||||
if params.method in {"pacf", "both"}:
|
||||
pacf_vals = _pacf(series.to_numpy(), nlags=params.max_lag)
|
||||
out: list[AutocorrelationData] = []
|
||||
for lag in range(params.max_lag + 1):
|
||||
acf_v = float(acf_vals[lag]) if acf_vals is not None else None
|
||||
pacf_v = float(pacf_vals[lag]) if pacf_vals is not None else None
|
||||
lower = float(acf_lower[lag]) if acf_lower is not None else None
|
||||
upper = float(acf_upper[lag]) if acf_upper is not None else None
|
||||
if acf_v is not None and lower is not None and upper is not None:
|
||||
significant = bool(acf_v < lower or acf_v > upper) and lag != 0
|
||||
else:
|
||||
significant = False
|
||||
out.append(
|
||||
AutocorrelationData(
|
||||
lag=lag,
|
||||
acf=acf_v,
|
||||
pacf=pacf_v,
|
||||
acf_confidence_lower=lower,
|
||||
acf_confidence_upper=upper,
|
||||
significant=significant,
|
||||
)
|
||||
)
|
||||
return OBBject(results=out)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AutocorrelationData",
|
||||
"AutocorrelationQueryParams",
|
||||
"ClenowData",
|
||||
"ClenowQueryParams",
|
||||
"DrawdownData",
|
||||
"DrawdownQueryParams",
|
||||
"Frequency",
|
||||
"HurstData",
|
||||
"HurstQueryParams",
|
||||
"ReturnsStatsData",
|
||||
"ReturnsStatsQueryParams",
|
||||
"StationarityData",
|
||||
"StationarityQueryParams",
|
||||
"autocorrelation",
|
||||
"clenow",
|
||||
"drawdown",
|
||||
"hurst",
|
||||
"returns_stats",
|
||||
"router",
|
||||
"stationarity",
|
||||
]
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Structure-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import calculate_fib_levels
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Structure indicators.")
|
||||
|
||||
|
||||
PivotMethod = Literal["classic", "fibonacci", "woodie", "camarilla", "demark"]
|
||||
PivotAnchor = Literal["day", "week", "month"]
|
||||
|
||||
_PIVOT_METHOD_TAG = {
|
||||
"classic": "CLAS",
|
||||
"fibonacci": "FIBO",
|
||||
"woodie": "WOOD",
|
||||
"camarilla": "CAMA",
|
||||
"demark": "DEMA",
|
||||
}
|
||||
|
||||
_PIVOT_ANCHOR_FREQ = {"day": "D", "week": "W", "month": "ME"}
|
||||
|
||||
|
||||
class FibQueryParams(QueryParams):
|
||||
"""Query parameters for the Fibonacci-retracement endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC(V) price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
close_column : {"close", "adj_close"}, optional
|
||||
Column used for high/low detection, by default ``"close"``.
|
||||
period : PositiveInt, optional
|
||||
Lookback in bars used to identify the swing high and swing low when
|
||||
``start_date`` and ``end_date`` are not provided, by default 120.
|
||||
start_date : str, optional
|
||||
Explicit retracement start date. When set together with ``end_date``,
|
||||
the swing endpoints are taken from this fixed window instead of from
|
||||
the trailing ``period``, by default ``None``.
|
||||
end_date : str, optional
|
||||
Explicit retracement end date paired with ``start_date``,
|
||||
by default ``None``.
|
||||
"""
|
||||
|
||||
__category__ = "structure"
|
||||
__output_columns__ = ("level", "price")
|
||||
|
||||
data: list[Data] = Field(description="OHLC(V) price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
close_column: Literal["close", "adj_close"] = Field(
|
||||
default="close",
|
||||
description="Column used for high/low detection.",
|
||||
)
|
||||
period: PositiveInt = Field(
|
||||
default=120, description="Lookback in bars for retracement."
|
||||
)
|
||||
start_date: str | None = Field(
|
||||
default=None, description="Explicit retracement start date."
|
||||
)
|
||||
end_date: str | None = Field(
|
||||
default=None, description="Explicit retracement end date."
|
||||
)
|
||||
|
||||
|
||||
class FibData(Data):
|
||||
"""One Fibonacci retracement level.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
level : str
|
||||
Retracement percentage label, e.g. ``"38.2%"``.
|
||||
price : float
|
||||
Price at the retracement level, expressed in the same units as the
|
||||
input ``close_column``.
|
||||
"""
|
||||
|
||||
level: str = Field(description="Retracement percentage label, e.g. ``38.2%``.")
|
||||
price: float = Field(description="Price at the retracement level.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Fibonacci retracement on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"fib = obb.technical.fib(data=data, period=120)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def fib(params: FibQueryParams) -> OBBject[list[FibData]]:
|
||||
"""Compute Fibonacci retracement levels between a swing high and swing low."""
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
df_fib, _, _, _, _, _ = calculate_fib_levels(
|
||||
data=df,
|
||||
close_col=params.close_column,
|
||||
limit=params.period,
|
||||
start_date=params.start_date,
|
||||
end_date=params.end_date,
|
||||
)
|
||||
out = df_fib.rename(columns={"Level": "level", "Price": "price"})
|
||||
return OBBject(results=[FibData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class DemarkQueryParams(QueryParams):
|
||||
"""Query parameters for the DeMark sequential endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Price series. Only the ``target`` column is consumed.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column on which the sequential counts are evaluated,
|
||||
by default ``"close"``.
|
||||
show_all : bool, optional
|
||||
When ``True`` show counts 1-13; when ``False`` show only counts 6-9,
|
||||
by default ``True``.
|
||||
asint : bool, optional
|
||||
Cast counts to integers and fill ``NaN`` with 0, by default ``True``.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "structure"
|
||||
__output_columns__ = ("date", "up", "down")
|
||||
|
||||
data: list[Data] = Field(description="Price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
target: str = Field(default="close", description="Column to evaluate.")
|
||||
show_all: bool = Field(
|
||||
default=True,
|
||||
description="When True show counts 1-13; False shows only 6-9.",
|
||||
)
|
||||
asint: bool = Field(
|
||||
default=True,
|
||||
description="Cast counts to integers and fill NaN with 0.",
|
||||
)
|
||||
offset: int = Field(default=0, description="Periods to offset the result.")
|
||||
|
||||
|
||||
class DemarkData(Data):
|
||||
"""One row of the DeMark sequential time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
up : int | float, optional
|
||||
Upward (buy-setup/countdown) sequential count on this bar. ``None`` or
|
||||
``0`` when no count is active.
|
||||
down : int | float, optional
|
||||
Downward (sell-setup/countdown) sequential count on this bar. ``None``
|
||||
or ``0`` when no count is active.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
up: int | float | None = Field(description="Upward sequential count.")
|
||||
down: int | float | None = Field(description="Downward sequential count.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="DeMark sequential on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"demark = obb.technical.demark(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def demark(params: DemarkQueryParams) -> OBBject[list[DemarkData]]:
|
||||
"""Compute Tom DeMark sequential up- and down-count exhaustion indicators."""
|
||||
import pandas_ta as ta
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df[params.target]
|
||||
raw = ta.exhc(
|
||||
series, asint=params.asint, show_all=params.show_all, offset=params.offset
|
||||
)
|
||||
rename = {"EXHC_DNa": "down", "EXHC_UPa": "up", "EXHC_DN": "down", "EXHC_UP": "up"}
|
||||
out = (
|
||||
raw.rename(columns=rename).reset_index().rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[DemarkData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class PivotPointsQueryParams(QueryParams):
|
||||
"""Query parameters for the pivot-points endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
method : PivotMethod, optional
|
||||
Pivot-point family. ``camarilla`` is the only method that fills the
|
||||
r4/s4 outer bands, by default ``"classic"``.
|
||||
anchor : PivotAnchor, optional
|
||||
Resample anchor for the pivot calculation — daily, weekly, or
|
||||
month-end, by default ``"day"``.
|
||||
"""
|
||||
|
||||
__category__ = "structure"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"pivot",
|
||||
"r1",
|
||||
"r2",
|
||||
"r3",
|
||||
"r4",
|
||||
"s1",
|
||||
"s2",
|
||||
"s3",
|
||||
"s4",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
method: PivotMethod = Field(
|
||||
default="classic",
|
||||
description="Pivot-point family. ``camarilla`` is the only one that fills r4/s4.",
|
||||
)
|
||||
anchor: PivotAnchor = Field(
|
||||
default="day",
|
||||
description="Resample anchor for the pivot calculation.",
|
||||
)
|
||||
|
||||
|
||||
class PivotPointsData(Data):
|
||||
"""One row of the pivot-points table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Period start date of the resampled pivot.
|
||||
pivot : float, optional
|
||||
Central pivot price for the period.
|
||||
r1 : float, optional
|
||||
First resistance band above the pivot.
|
||||
r2 : float, optional
|
||||
Second resistance band above the pivot.
|
||||
r3 : float, optional
|
||||
Third resistance band above the pivot.
|
||||
r4 : float, optional
|
||||
Fourth resistance band; only populated when ``method='camarilla'``.
|
||||
s1 : float, optional
|
||||
First support band below the pivot.
|
||||
s2 : float, optional
|
||||
Second support band below the pivot.
|
||||
s3 : float, optional
|
||||
Third support band below the pivot.
|
||||
s4 : float, optional
|
||||
Fourth support band; only populated when ``method='camarilla'``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(
|
||||
description="Period start date of the resampled pivot."
|
||||
)
|
||||
pivot: float | None = Field(description="Central pivot price.")
|
||||
r1: float | None = Field(
|
||||
default=None, description="First resistance band above the pivot."
|
||||
)
|
||||
r2: float | None = Field(
|
||||
default=None, description="Second resistance band above the pivot."
|
||||
)
|
||||
r3: float | None = Field(
|
||||
default=None, description="Third resistance band above the pivot."
|
||||
)
|
||||
r4: float | None = Field(default=None, description="Only populated for camarilla.")
|
||||
s1: float | None = Field(
|
||||
default=None, description="First support band below the pivot."
|
||||
)
|
||||
s2: float | None = Field(
|
||||
default=None, description="Second support band below the pivot."
|
||||
)
|
||||
s3: float | None = Field(
|
||||
default=None, description="Third support band below the pivot."
|
||||
)
|
||||
s4: float | None = Field(default=None, description="Only populated for camarilla.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Classic daily pivot points on TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"pivots = obb.technical.pivot_points(data=data, method='classic', anchor='day')",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def pivot_points(params: PivotPointsQueryParams) -> OBBject[list[PivotPointsData]]:
|
||||
"""Compute pivot-point support and resistance bands under multiple methods."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
if params.index == "date":
|
||||
df.index = pd.to_datetime(df.index)
|
||||
freq = _PIVOT_ANCHOR_FREQ[params.anchor]
|
||||
pivots_df = df.ta.pivots(method=params.method, anchor=freq)
|
||||
tag = _PIVOT_METHOD_TAG[params.method]
|
||||
prefix = f"PIVOTS_{tag}_{freq}_"
|
||||
rename = {
|
||||
f"{prefix}P": "pivot",
|
||||
f"{prefix}R1": "r1",
|
||||
f"{prefix}R2": "r2",
|
||||
f"{prefix}R3": "r3",
|
||||
f"{prefix}R4": "r4",
|
||||
f"{prefix}S1": "s1",
|
||||
f"{prefix}S2": "s2",
|
||||
f"{prefix}S3": "s3",
|
||||
f"{prefix}S4": "s4",
|
||||
}
|
||||
out = pivots_df.rename(columns=rename)
|
||||
for col in ("pivot", "r1", "r2", "r3", "r4", "s1", "s2", "s3", "s4"):
|
||||
if col not in out.columns:
|
||||
out[col] = None
|
||||
if params.method != "camarilla":
|
||||
out["r4"] = None
|
||||
out["s4"] = None
|
||||
out = out[["pivot", "r1", "r2", "r3", "r4", "s1", "s2", "s3", "s4"]]
|
||||
out = (
|
||||
out.dropna(subset=["pivot"])
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
out = out.where(pd.notna(out), None)
|
||||
return OBBject(
|
||||
results=[PivotPointsData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DemarkData",
|
||||
"DemarkQueryParams",
|
||||
"FibData",
|
||||
"FibQueryParams",
|
||||
"PivotAnchor",
|
||||
"PivotMethod",
|
||||
"PivotPointsData",
|
||||
"PivotPointsQueryParams",
|
||||
"demark",
|
||||
"fib",
|
||||
"pivot_points",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Trend-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveFloat, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Trend indicators.")
|
||||
|
||||
|
||||
class MacdQueryParams(QueryParams):
|
||||
"""Query parameters for the MACD endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series containing the ``target`` column.
|
||||
target : str, optional
|
||||
Column to compute MACD on, by default ``"close"``.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast : PositiveInt, optional
|
||||
Fast EMA window in bars, by default 12.
|
||||
slow : PositiveInt, optional
|
||||
Slow EMA window in bars, by default 26.
|
||||
signal : PositiveInt, optional
|
||||
Signal-line EMA window in bars, by default 9.
|
||||
"""
|
||||
|
||||
__category__ = "trend"
|
||||
__output_columns__ = ("date", "macd", "signal", "histogram")
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
target: str = Field(default="close", description="Column to compute MACD on.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
fast: PositiveInt = Field(default=12, description="Fast EMA window.")
|
||||
slow: PositiveInt = Field(default=26, description="Slow EMA window.")
|
||||
signal: PositiveInt = Field(default=9, description="Signal-line EMA window.")
|
||||
|
||||
|
||||
class MacdData(Data):
|
||||
"""One row of the MACD time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
macd : float, optional
|
||||
MACD line — fast EMA of ``target`` minus slow EMA of ``target``.
|
||||
signal : float, optional
|
||||
Signal line — EMA of the MACD line over ``signal`` bars.
|
||||
histogram : float, optional
|
||||
MACD line minus signal line.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
macd: float | None = Field(description="MACD line — fast EMA minus slow EMA.")
|
||||
signal: float | None = Field(description="Signal line — EMA of MACD.")
|
||||
histogram: float | None = Field(description="MACD minus signal.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="MACD(12,26,9) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"macd = obb.technical.macd(data=data, fast=12, slow=26, signal=9)",
|
||||
],
|
||||
),
|
||||
APIEx(
|
||||
description="MACD with short windows for mock data.",
|
||||
parameters={
|
||||
"data": APIEx.mock_data("timeseries"),
|
||||
"fast": 2,
|
||||
"slow": 3,
|
||||
"signal": 1,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def macd(params: MacdQueryParams) -> OBBject[list[MacdData]]:
|
||||
"""Calculate the Moving Average Convergence Divergence (MACD) oscillator."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.fast, params.slow, params.signal])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
result = (
|
||||
df[params.target]
|
||||
.to_frame()
|
||||
.ta.macd(
|
||||
fast=params.fast,
|
||||
slow=params.slow,
|
||||
signal=params.signal,
|
||||
close=params.target,
|
||||
)
|
||||
)
|
||||
rename = {
|
||||
f"MACD_{params.fast}_{params.slow}_{params.signal}": "macd",
|
||||
f"MACDs_{params.fast}_{params.slow}_{params.signal}": "signal",
|
||||
f"MACDh_{params.fast}_{params.slow}_{params.signal}": "histogram",
|
||||
}
|
||||
out = (
|
||||
result.rename(columns=rename)
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
out = out[["date", "macd", "signal", "histogram"]]
|
||||
return OBBject(results=[MacdData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class AdxQueryParams(QueryParams):
|
||||
"""Query parameters for the ADX endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
ADX lookback window in bars, by default 14.
|
||||
scalar : PositiveFloat, optional
|
||||
Output magnification factor, by default 100.0.
|
||||
drift : PositiveInt, optional
|
||||
Difference period for directional movement, by default 1.
|
||||
"""
|
||||
|
||||
__category__ = "trend"
|
||||
__output_columns__ = ("date", "adx")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="ADX lookback window.")
|
||||
scalar: PositiveFloat = Field(
|
||||
default=100.0, description="Output magnification factor."
|
||||
)
|
||||
drift: PositiveInt = Field(
|
||||
default=1, description="Difference period for directional movement."
|
||||
)
|
||||
|
||||
|
||||
class AdxData(Data):
|
||||
"""One row of the ADX time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
adx : float, optional
|
||||
Average Directional Index over the trailing ``length`` bars. ``None``
|
||||
for warm-up rows preceding ``length``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
adx: float | None = Field(
|
||||
description="Average Directional Index — trend-strength score."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="ADX(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"adx = obb.technical.adx(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "length": 2}),
|
||||
],
|
||||
)
|
||||
def adx(params: AdxQueryParams) -> OBBject[list[AdxData]]:
|
||||
"""Calculate Wilder's Average Directional Index (ADX), a trend-strength gauge."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
result = df.ta.adx(length=params.length, scalar=params.scalar, drift=params.drift)
|
||||
series = result[f"ADX_{params.length}"].rename("adx")
|
||||
out = (
|
||||
series.to_frame().dropna().reset_index().rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[AdxData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class DiQueryParams(QueryParams):
|
||||
"""Query parameters for the Directional Indicators endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window for +DI and -DI in bars, by default 14.
|
||||
scalar : PositiveFloat, optional
|
||||
Output magnification factor, by default 100.0.
|
||||
drift : PositiveInt, optional
|
||||
Difference period for directional movement, by default 1.
|
||||
"""
|
||||
|
||||
__category__ = "trend"
|
||||
__output_columns__ = ("date", "plus_di", "minus_di", "dx")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(
|
||||
default=14, description="Lookback window for +DI / -DI."
|
||||
)
|
||||
scalar: PositiveFloat = Field(
|
||||
default=100.0, description="Output magnification factor."
|
||||
)
|
||||
drift: PositiveInt = Field(
|
||||
default=1, description="Difference period for directional movement."
|
||||
)
|
||||
|
||||
|
||||
class DiData(Data):
|
||||
"""One row of the Directional Indicators time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
plus_di : float, optional
|
||||
Positive Directional Indicator (+DI) — Wilder-smoothed upward
|
||||
directional movement, scaled by ``scalar``.
|
||||
minus_di : float, optional
|
||||
Negative Directional Indicator (-DI) — Wilder-smoothed downward
|
||||
directional movement, scaled by ``scalar``.
|
||||
dx : float, optional
|
||||
Raw Directional Index ``|+DI - -DI| / (+DI + -DI) * scalar``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
plus_di: float | None = Field(description="Positive Directional Indicator (+DI).")
|
||||
minus_di: float | None = Field(description="Negative Directional Indicator (-DI).")
|
||||
dx: float | None = Field(
|
||||
description="Directional Index — |+DI - -DI| / (+DI + -DI) * scalar."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="+DI / -DI / DX(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"di = obb.technical.di(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "length": 2}),
|
||||
],
|
||||
)
|
||||
def di(params: DiQueryParams) -> OBBject[list[DiData]]:
|
||||
"""Calculate the Directional Indicators (+DI, -DI) and raw DX from Wilder's system."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
result = df.ta.adx(length=params.length, scalar=params.scalar, drift=params.drift)
|
||||
plus = result[f"DMP_{params.length}"]
|
||||
minus = result[f"DMN_{params.length}"]
|
||||
total = plus + minus
|
||||
dx_series = (plus - minus).abs() / total.where(total != 0) * params.scalar
|
||||
out = (
|
||||
plus.to_frame(name="plus_di")
|
||||
.assign(minus_di=minus, dx=dx_series)
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[DiData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class AroonQueryParams(QueryParams):
|
||||
"""Query parameters for the Aroon endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high`` and ``low`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Aroon lookback window in bars, by default 25.
|
||||
scalar : PositiveFloat, optional
|
||||
Output magnification factor, by default 100.0.
|
||||
"""
|
||||
|
||||
__category__ = "trend"
|
||||
__output_columns__ = ("date", "aroon_up", "aroon_down", "aroon_oscillator")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=25, description="Aroon lookback window.")
|
||||
scalar: PositiveFloat = Field(
|
||||
default=100.0, description="Output magnification factor."
|
||||
)
|
||||
|
||||
|
||||
class AroonData(Data):
|
||||
"""One row of the Aroon time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
aroon_up : float, optional
|
||||
Bars since the highest high over the trailing ``length`` bars, scaled
|
||||
to ``[0, scalar]``. High values indicate a recent new high.
|
||||
aroon_down : float, optional
|
||||
Bars since the lowest low over the trailing ``length`` bars, scaled
|
||||
to ``[0, scalar]``. High values indicate a recent new low.
|
||||
aroon_oscillator : float, optional
|
||||
Difference ``aroon_up - aroon_down``; positive in uptrends, negative
|
||||
in downtrends.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
aroon_up: float | None = Field(description="Bars since the highest high, scaled.")
|
||||
aroon_down: float | None = Field(description="Bars since the lowest low, scaled.")
|
||||
aroon_oscillator: float | None = Field(
|
||||
description="``aroon_up`` minus ``aroon_down``."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Aroon(25) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"aroon = obb.technical.aroon(data=data, length=25)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "length": 2}),
|
||||
],
|
||||
)
|
||||
def aroon(params: AroonQueryParams) -> OBBject[list[AroonData]]:
|
||||
"""Calculate the Aroon Up, Aroon Down, and Aroon Oscillator series."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
result = df.ta.aroon(length=params.length, scalar=params.scalar)
|
||||
rename = {
|
||||
f"AROONU_{params.length}": "aroon_up",
|
||||
f"AROOND_{params.length}": "aroon_down",
|
||||
f"AROONOSC_{params.length}": "aroon_oscillator",
|
||||
}
|
||||
out = (
|
||||
result.rename(columns=rename)
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
out = out[["date", "aroon_up", "aroon_down", "aroon_oscillator"]]
|
||||
return OBBject(results=[AroonData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class ChoppinessQueryParams(QueryParams):
|
||||
"""Query parameters for the Choppiness Index endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
Choppiness lookback window in bars, by default 14.
|
||||
atr_length : PositiveInt, optional
|
||||
ATR window used inside the choppiness calculation, by default 1.
|
||||
scalar : PositiveFloat, optional
|
||||
Output magnification factor, by default 100.0.
|
||||
"""
|
||||
|
||||
__category__ = "trend"
|
||||
__output_columns__ = ("date", "choppiness")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="Choppiness lookback window.")
|
||||
atr_length: PositiveInt = Field(
|
||||
default=1, description="ATR window used inside choppiness."
|
||||
)
|
||||
scalar: PositiveFloat = Field(
|
||||
default=100.0, description="Output magnification factor."
|
||||
)
|
||||
|
||||
|
||||
class ChoppinessData(Data):
|
||||
"""One row of the Choppiness Index time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
choppiness : float, optional
|
||||
Choppiness Index over the trailing ``length`` bars. High values
|
||||
(typically above ~61.8) indicate sideways, choppy action; low values
|
||||
(typically below ~38.2) indicate a directional trend.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
choppiness: float | None = Field(
|
||||
description="Choppiness Index — high values mean sideways action."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Choppiness(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"chop = obb.technical.choppiness(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "length": 2}),
|
||||
],
|
||||
)
|
||||
def choppiness(params: ChoppinessQueryParams) -> OBBject[list[ChoppinessData]]:
|
||||
"""Calculate the Choppiness Index, a trending-vs-sideways regime classifier."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length, params.atr_length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.chop(
|
||||
length=params.length,
|
||||
atr_length=params.atr_length,
|
||||
scalar=params.scalar,
|
||||
).rename("choppiness")
|
||||
out = (
|
||||
series.to_frame().dropna().reset_index().rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(
|
||||
results=[ChoppinessData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdxData",
|
||||
"AdxQueryParams",
|
||||
"AroonData",
|
||||
"AroonQueryParams",
|
||||
"ChoppinessData",
|
||||
"ChoppinessQueryParams",
|
||||
"DiData",
|
||||
"DiQueryParams",
|
||||
"MacdData",
|
||||
"MacdQueryParams",
|
||||
"adx",
|
||||
"aroon",
|
||||
"choppiness",
|
||||
"di",
|
||||
"macd",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,599 @@
|
||||
"""Volatility-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt, field_validator
|
||||
|
||||
from openbb_technical.helpers import (
|
||||
calculate_cones,
|
||||
garman_klass,
|
||||
hodges_tompkins,
|
||||
parkinson,
|
||||
rogers_satchell,
|
||||
standard_deviation,
|
||||
validate_data,
|
||||
yang_zhang,
|
||||
)
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
router = Router(prefix="", description="Volatility indicators.")
|
||||
|
||||
|
||||
RealizedVolatilityModel = Literal[
|
||||
"std",
|
||||
"parkinson",
|
||||
"garman_klass",
|
||||
"hodges_tompkins",
|
||||
"rogers_satchell",
|
||||
"yang_zhang",
|
||||
]
|
||||
|
||||
|
||||
_VOL_FUNCTIONS = {
|
||||
"std": standard_deviation,
|
||||
"parkinson": parkinson,
|
||||
"garman_klass": garman_klass,
|
||||
"hodges_tompkins": hodges_tompkins,
|
||||
"rogers_satchell": rogers_satchell,
|
||||
"yang_zhang": yang_zhang,
|
||||
}
|
||||
|
||||
|
||||
class RealizedVolatilityQueryParams(QueryParams):
|
||||
"""Query parameters for the realized-volatility endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC(V) price series. ``parkinson``, ``garman_klass``, ``rogers_satchell``,
|
||||
and ``yang_zhang`` require ``high`` and ``low`` columns; ``garman_klass``
|
||||
and ``yang_zhang`` additionally require ``open``.
|
||||
model : RealizedVolatilityModel, optional
|
||||
Volatility estimator, by default ``yang_zhang``. ``yang_zhang`` has the
|
||||
lowest variance among the six and accommodates overnight drift;
|
||||
``parkinson`` and ``rogers_satchell`` are range-based and drift-invariant;
|
||||
``hodges_tompkins`` is a bias-corrected close-to-close estimator.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
window : PositiveInt, optional
|
||||
Rolling window length in bars, by default 30.
|
||||
trading_periods : PositiveInt, optional
|
||||
Annualisation factor. When unset, resolves to 365 if ``is_crypto`` is
|
||||
``True``, otherwise 252.
|
||||
is_crypto : bool, optional
|
||||
When ``True`` and ``trading_periods`` is unset, annualise over 365
|
||||
instead of 252, by default ``False``.
|
||||
clean : bool, optional
|
||||
Drop the leading warm-up rows where the rolling window has not yet
|
||||
filled, by default ``True``.
|
||||
"""
|
||||
|
||||
__category__ = "volatility"
|
||||
__output_columns__ = ("date", "volatility", "model", "window", "trading_periods")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLC(V) price series.")
|
||||
model: RealizedVolatilityModel = Field(
|
||||
default="yang_zhang",
|
||||
description=(
|
||||
"Volatility estimator. ``yang_zhang`` is the lowest-error blend; "
|
||||
"``parkinson`` and ``rogers_satchell`` are range-based and "
|
||||
"drift-invariant; ``hodges_tompkins`` is bias-corrected close-to-close."
|
||||
),
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
window: PositiveInt = Field(
|
||||
default=30, description="Rolling window length in bars."
|
||||
)
|
||||
trading_periods: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Annualisation factor. ``None`` resolves to 365 when ``is_crypto`` "
|
||||
"is true, otherwise 252."
|
||||
),
|
||||
)
|
||||
is_crypto: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When true and ``trading_periods`` is unset, annualise over 365 "
|
||||
"instead of 252."
|
||||
),
|
||||
)
|
||||
clean: bool = Field(
|
||||
default=True,
|
||||
description="Drop the leading warm-up rows where the rolling window is incomplete.",
|
||||
)
|
||||
|
||||
@field_validator("window")
|
||||
@classmethod
|
||||
def window_minimum_for_model(cls, v: int, info) -> int:
|
||||
"""Enforce ``window >= 2`` for ``hodges_tompkins`` and ``yang_zhang``."""
|
||||
model = info.data.get("model", "std")
|
||||
if model in {"hodges_tompkins", "yang_zhang"} and v < 2:
|
||||
raise ValueError(f"{model} requires window >= 2.")
|
||||
return v
|
||||
|
||||
|
||||
class RealizedVolatilityData(Data):
|
||||
"""One row of the realized-volatility time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
volatility : float, optional
|
||||
Annualised realised volatility per the chosen ``model`` over the trailing
|
||||
``window`` bars. ``None`` during warm-up when ``clean=False``.
|
||||
model : RealizedVolatilityModel
|
||||
Estimator used to produce this row.
|
||||
window : int
|
||||
Rolling window length used to produce this row.
|
||||
trading_periods : int
|
||||
Annualisation factor used to produce this row.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
volatility: float | None = Field(
|
||||
description=(
|
||||
"Annualised realised volatility per the chosen ``model`` over "
|
||||
"the trailing ``window`` bars. ``None`` during warm-up when "
|
||||
"``clean=False``."
|
||||
),
|
||||
)
|
||||
model: RealizedVolatilityModel = Field(
|
||||
description="Echoed on every row so the response is self-describing.",
|
||||
)
|
||||
window: int = Field(description="Window used to produce this row.")
|
||||
trading_periods: int = Field(
|
||||
description="Annualisation factor used to produce this row."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Yang-Zhang realised volatility on daily SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"rv = obb.technical.realized_volatility(data=data, model='yang_zhang', window=30)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "model": "parkinson"}),
|
||||
],
|
||||
)
|
||||
def realized_volatility(
|
||||
params: RealizedVolatilityQueryParams,
|
||||
) -> OBBject[list[RealizedVolatilityData]]:
|
||||
"""Calculate annualised rolling realised volatility under a chosen estimator."""
|
||||
validate_data(params.data, [params.window])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = _VOL_FUNCTIONS[params.model](
|
||||
df,
|
||||
window=params.window,
|
||||
trading_periods=params.trading_periods,
|
||||
is_crypto=params.is_crypto,
|
||||
clean=params.clean,
|
||||
)
|
||||
resolved_tp = params.trading_periods or (365 if params.is_crypto else 252)
|
||||
out = (
|
||||
series.to_frame(name="volatility")
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
out["model"] = params.model
|
||||
out["window"] = params.window
|
||||
out["trading_periods"] = resolved_tp
|
||||
return OBBject(
|
||||
results=[RealizedVolatilityData(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
class RealizedVolatilityCompareQueryParams(QueryParams):
|
||||
"""Query parameters for the side-by-side realized-volatility endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. All four price columns (``open``, ``high``, ``low``,
|
||||
``close``) are required because every estimator family is evaluated.
|
||||
models : list[RealizedVolatilityModel], optional
|
||||
Estimators to compute, defaults to all six.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
window : PositiveInt, optional
|
||||
Rolling window length in bars, by default 30.
|
||||
trading_periods : PositiveInt, optional
|
||||
Annualisation factor. When unset, resolves to 365 if ``is_crypto`` is
|
||||
``True``, otherwise 252.
|
||||
is_crypto : bool, optional
|
||||
When ``True`` and ``trading_periods`` is unset, annualise over 365
|
||||
instead of 252, by default ``False``.
|
||||
clean : bool, optional
|
||||
Drop rows where any requested model has not yet warmed up, by default
|
||||
``True``.
|
||||
"""
|
||||
|
||||
__category__ = "volatility"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"std",
|
||||
"parkinson",
|
||||
"garman_klass",
|
||||
"hodges_tompkins",
|
||||
"rogers_satchell",
|
||||
"yang_zhang",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(
|
||||
description="Input OHLC price series — every column required."
|
||||
)
|
||||
models: list[RealizedVolatilityModel] = Field( # ty: ignore[invalid-assignment]
|
||||
default_factory=lambda: [
|
||||
"std",
|
||||
"parkinson",
|
||||
"garman_klass",
|
||||
"hodges_tompkins",
|
||||
"rogers_satchell",
|
||||
"yang_zhang",
|
||||
],
|
||||
description="Estimators to compute. Defaults to all six.",
|
||||
)
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
window: PositiveInt = Field(
|
||||
default=30, description="Rolling window length in bars, by default 30."
|
||||
)
|
||||
trading_periods: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description="Annualisation factor. When unset, resolves to 365 if ``is_crypto`` is ``True``, otherwise 252.",
|
||||
)
|
||||
is_crypto: bool = Field(
|
||||
default=False,
|
||||
description="When ``True`` and ``trading_periods`` is unset, annualise over 365 instead of 252, by default ``False``.",
|
||||
)
|
||||
clean: bool = Field(
|
||||
default=True,
|
||||
description="Drop rows where ANY requested model has not yet warmed up.",
|
||||
)
|
||||
|
||||
|
||||
class RealizedVolatilityCompareData(Data):
|
||||
"""One row aligning every requested estimator on the same date.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
std : float, optional
|
||||
Close-to-close standard-deviation volatility.
|
||||
parkinson : float, optional
|
||||
Parkinson range-based volatility.
|
||||
garman_klass : float, optional
|
||||
Garman-Klass OHLC volatility.
|
||||
hodges_tompkins : float, optional
|
||||
Bias-corrected close-to-close volatility.
|
||||
rogers_satchell : float, optional
|
||||
Rogers-Satchell drift-invariant volatility.
|
||||
yang_zhang : float, optional
|
||||
Yang-Zhang minimum-variance volatility.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
std: float | None = Field(
|
||||
default=None, description="Close-to-close standard-deviation volatility."
|
||||
)
|
||||
parkinson: float | None = Field(
|
||||
default=None, description="Parkinson range-based volatility."
|
||||
)
|
||||
garman_klass: float | None = Field(
|
||||
default=None, description="Garman-Klass OHLC volatility."
|
||||
)
|
||||
hodges_tompkins: float | None = Field(
|
||||
default=None, description="Bias-corrected close-to-close volatility."
|
||||
)
|
||||
rogers_satchell: float | None = Field(
|
||||
default=None, description="Rogers-Satchell drift-invariant volatility."
|
||||
)
|
||||
yang_zhang: float | None = Field(
|
||||
default=None, description="Yang-Zhang minimum-variance volatility."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
|
||||
)
|
||||
def realized_volatility_compare(
|
||||
params: RealizedVolatilityCompareQueryParams,
|
||||
) -> OBBject[list[RealizedVolatilityCompareData]]:
|
||||
"""Compute realised volatility under every requested estimator, side by side."""
|
||||
import pandas as pd
|
||||
|
||||
validate_data(params.data, [params.window])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
columns: dict[str, pd.Series] = {}
|
||||
for m in params.models:
|
||||
columns[m] = _VOL_FUNCTIONS[m]( # ty: ignore[invalid-assignment]
|
||||
df,
|
||||
window=params.window,
|
||||
trading_periods=params.trading_periods,
|
||||
is_crypto=params.is_crypto,
|
||||
clean=False,
|
||||
)
|
||||
wide = pd.DataFrame(columns)
|
||||
if params.clean:
|
||||
wide = wide.dropna(how="any")
|
||||
wide = wide.reset_index().rename(columns={params.index: "date"})
|
||||
return OBBject(
|
||||
results=[
|
||||
RealizedVolatilityCompareData(**row)
|
||||
for row in wide.to_dict(orient="records")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ConesQueryParams(QueryParams):
|
||||
"""Query parameters for the volatility-cones endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
lower_q : float, optional
|
||||
Lower quantile for the cone band, by default 0.25. Must be in
|
||||
``(0, 1)`` and strictly less than ``upper_q``.
|
||||
upper_q : float, optional
|
||||
Upper quantile for the cone band, by default 0.75. Must be in
|
||||
``(0, 1)`` and strictly greater than ``lower_q``.
|
||||
model : RealizedVolatilityModel, optional
|
||||
Estimator applied to each rolling window, by default ``"std"``.
|
||||
is_crypto : bool, optional
|
||||
When ``True`` and ``trading_periods`` is unset, annualise over 365
|
||||
instead of 252, by default ``False``.
|
||||
trading_periods : PositiveInt, optional
|
||||
Annualisation factor. When unset, resolves to 365 if ``is_crypto`` is
|
||||
``True``, otherwise 252.
|
||||
"""
|
||||
|
||||
__category__ = "volatility"
|
||||
__output_columns__ = (
|
||||
"window",
|
||||
"realized",
|
||||
"min",
|
||||
"lower",
|
||||
"median",
|
||||
"upper",
|
||||
"max",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
lower_q: float = Field(
|
||||
default=0.25,
|
||||
description="Lower quantile for the cone band (0–1, exclusive of 1).",
|
||||
)
|
||||
upper_q: float = Field(
|
||||
default=0.75,
|
||||
description="Upper quantile for the cone band (0–1, exclusive of 1).",
|
||||
)
|
||||
model: RealizedVolatilityModel = Field(
|
||||
default="std",
|
||||
description='Estimator applied to each rolling window, by default ``"std"``.',
|
||||
)
|
||||
is_crypto: bool = Field(
|
||||
default=False,
|
||||
description="When ``True`` and ``trading_periods`` is unset, annualise over 365 instead of 252, by default ``False``.",
|
||||
)
|
||||
trading_periods: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description="Annualisation factor. When unset, resolves to 365 if ``is_crypto`` is ``True``, otherwise 252.",
|
||||
)
|
||||
|
||||
|
||||
class ConesData(Data):
|
||||
"""One row per analysis window for the cones table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
window : int
|
||||
Rolling window length in bars (typically 10, 30, 60, 90, 120, 150, 180,
|
||||
210, 240, 270, 300, 330, 360).
|
||||
realized : float, optional
|
||||
Most-recent annualised volatility at this window.
|
||||
min : float, optional
|
||||
Historical minimum of annualised volatility at this window.
|
||||
lower : float, optional
|
||||
Lower-quantile cone band at this window.
|
||||
median : float, optional
|
||||
Historical median of annualised volatility at this window.
|
||||
upper : float, optional
|
||||
Upper-quantile cone band at this window.
|
||||
max : float, optional
|
||||
Historical maximum of annualised volatility at this window.
|
||||
"""
|
||||
|
||||
window: int = Field(description="Rolling window length in bars.")
|
||||
realized: float | None = Field(description="Most-recent annualised volatility.")
|
||||
min: float | None = Field(
|
||||
description="Historical minimum of annualised volatility at this window."
|
||||
)
|
||||
lower: float | None = Field(description="Lower-quantile cone band.")
|
||||
median: float | None = Field(
|
||||
description="Historical median of annualised volatility at this window."
|
||||
)
|
||||
upper: float | None = Field(description="Upper-quantile cone band.")
|
||||
max: float | None = Field(
|
||||
description="Historical maximum of annualised volatility at this window."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Get volatility cones for the past two years of SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"cones = obb.technical.cones(data=data, model='yang_zhang')",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def cones(params: ConesQueryParams) -> OBBject[list[ConesData]]:
|
||||
"""Build a volatility-cone snapshot summarising realised volatility by window."""
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
table = calculate_cones(
|
||||
df,
|
||||
lower_q=params.lower_q,
|
||||
upper_q=params.upper_q,
|
||||
is_crypto=params.is_crypto,
|
||||
model=params.model,
|
||||
trading_periods=params.trading_periods,
|
||||
)
|
||||
rename = {
|
||||
col: (
|
||||
"lower"
|
||||
if col.startswith("lower_")
|
||||
else "upper"
|
||||
if col.startswith("upper_")
|
||||
else col
|
||||
)
|
||||
for col in table.columns
|
||||
}
|
||||
out = table.rename(columns=rename).copy()
|
||||
out["window"] = out["window"].astype(int)
|
||||
return OBBject(results=[ConesData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class AtrQueryParams(QueryParams):
|
||||
"""Query parameters for the Average True Range endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series. ``high``, ``low``, and ``close`` are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
length : PositiveInt, optional
|
||||
ATR lookback window, by default 14.
|
||||
mamode : {"sma", "ema", "wma", "rma"}, optional
|
||||
Smoothing applied to true-range. ``rma`` is the Wilder default,
|
||||
by default ``"rma"``.
|
||||
drift : PositiveInt, optional
|
||||
Difference period for true-range, by default 1.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "volatility"
|
||||
__output_columns__ = ("date", "atr")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
length: PositiveInt = Field(default=14, description="ATR lookback window.")
|
||||
mamode: Literal["sma", "ema", "wma", "rma"] = Field(
|
||||
default="rma",
|
||||
description="Smoothing applied to true-range. ``rma`` is the Wilder default.",
|
||||
)
|
||||
drift: PositiveInt = Field(
|
||||
default=1, description="Difference period for true-range."
|
||||
)
|
||||
offset: int = Field(
|
||||
default=0,
|
||||
description="Shift the output series by this many bars, by default 0.",
|
||||
)
|
||||
|
||||
|
||||
class AtrData(Data):
|
||||
"""One row of the ATR time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
atr : float, optional
|
||||
Average true range over the trailing ``length`` bars. ``None`` for the
|
||||
warm-up rows preceding ``length``.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
atr: float | None = Field(
|
||||
description="Average true range over the trailing ``length`` bars."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="ATR(14) on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"atr = obb.technical.atr(data=data, length=14)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "length": 14}),
|
||||
],
|
||||
)
|
||||
def atr(params: AtrQueryParams) -> OBBject[list[AtrData]]:
|
||||
"""Calculate the Average True Range (ATR), Wilder's volatility measure."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.atr(
|
||||
length=params.length,
|
||||
mamode=params.mamode,
|
||||
drift=params.drift,
|
||||
offset=params.offset,
|
||||
)
|
||||
out = (
|
||||
pd.DataFrame({"atr": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[AtrData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AtrData",
|
||||
"AtrQueryParams",
|
||||
"ConesData",
|
||||
"ConesQueryParams",
|
||||
"RealizedVolatilityCompareData",
|
||||
"RealizedVolatilityCompareQueryParams",
|
||||
"RealizedVolatilityData",
|
||||
"RealizedVolatilityQueryParams",
|
||||
"RealizedVolatilityModel",
|
||||
"atr",
|
||||
"cones",
|
||||
"realized_volatility",
|
||||
"realized_volatility_compare",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Volume-family technical indicators."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Volume indicators.")
|
||||
|
||||
|
||||
class ObvQueryParams(QueryParams):
|
||||
"""Query parameters for the On-Balance Volume endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLCV price series. ``close`` and ``volume`` columns are required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "volume"
|
||||
__output_columns__ = ("date", "obv")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLCV price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
offset: int = Field(default=0, description="Periods to offset the result.")
|
||||
|
||||
|
||||
class ObvData(Data):
|
||||
"""One row of the On-Balance Volume time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
obv : float, optional
|
||||
On-Balance Volume cumulative total at this bar.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
obv: float | None = Field(description="On-Balance Volume cumulative total.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="OBV on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"obv = obb.technical.obv(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def obv(params: ObvQueryParams) -> OBBject[list[ObvData]]:
|
||||
"""Calculate On-Balance Volume (OBV), a cumulative volume-flow indicator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.obv(offset=params.offset)
|
||||
out = (
|
||||
pd.DataFrame({"obv": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[ObvData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class AdQueryParams(QueryParams):
|
||||
"""Query parameters for the Accumulation/Distribution line endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLCV price series. ``high``, ``low``, ``close``, and ``volume`` are
|
||||
required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "volume"
|
||||
__output_columns__ = ("date", "ad")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLCV price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
offset: int = Field(default=0, description="Periods to offset the result.")
|
||||
|
||||
|
||||
class AdData(Data):
|
||||
"""One row of the Accumulation/Distribution line time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
ad : float, optional
|
||||
Accumulation/Distribution cumulative line value at this bar.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
ad: float | None = Field(description="Accumulation/Distribution cumulative line.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Accumulation/Distribution line on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"ad = obb.technical.ad(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def ad(params: AdQueryParams) -> OBBject[list[AdData]]:
|
||||
"""Calculate the Accumulation/Distribution line, a volume-flow indicator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.ad(offset=params.offset)
|
||||
out = (
|
||||
pd.DataFrame({"ad": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[AdData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class AdoscQueryParams(QueryParams):
|
||||
"""Query parameters for the Chaikin Accumulation/Distribution Oscillator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLCV price series. ``high``, ``low``, ``close``, and ``volume`` are
|
||||
required.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast : PositiveInt, optional
|
||||
Fast EMA window applied to the Accumulation/Distribution line, by
|
||||
default 3.
|
||||
slow : PositiveInt, optional
|
||||
Slow EMA window applied to the Accumulation/Distribution line, by
|
||||
default 10.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "volume"
|
||||
__output_columns__ = ("date", "adosc")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLCV price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
fast: PositiveInt = Field(default=3, description="Fast EMA window.")
|
||||
slow: PositiveInt = Field(default=10, description="Slow EMA window.")
|
||||
offset: int = Field(default=0, description="Periods to offset the result.")
|
||||
|
||||
|
||||
class AdoscData(Data):
|
||||
"""One row of the Chaikin Oscillator time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
adosc : float, optional
|
||||
Chaikin Accumulation/Distribution Oscillator value at this bar.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
adosc: float | None = Field(
|
||||
description="Chaikin Accumulation/Distribution Oscillator."
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Chaikin Oscillator on daily TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"adosc = obb.technical.adosc(data=data, fast=3, slow=10)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"fast": 2, "slow": 4, "data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def adosc(params: AdoscQueryParams) -> OBBject[list[AdoscData]]:
|
||||
"""Calculate the Chaikin Accumulation/Distribution Oscillator."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.fast, params.slow])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df.ta.adosc(fast=params.fast, slow=params.slow, offset=params.offset)
|
||||
out = (
|
||||
pd.DataFrame({"adosc": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[AdoscData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
class VwapQueryParams(QueryParams):
|
||||
"""Query parameters for the Volume-Weighted Average Price endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLCV price series. ``high``, ``low``, ``close``, and ``volume`` are
|
||||
required, and the index must be parseable as a datetime so that the
|
||||
resample anchor can be applied.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
anchor : str, optional
|
||||
Resample anchor — pandas offset alias such as ``"D"`` (daily),
|
||||
``"W"`` (weekly), or ``"ME"`` (month-end). Determines the boundary at
|
||||
which the VWAP accumulator resets, by default ``"D"``.
|
||||
offset : int, optional
|
||||
Shift the output series by this many bars, by default 0.
|
||||
"""
|
||||
|
||||
__category__ = "volume"
|
||||
__output_columns__ = ("date", "vwap")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLCV price series.")
|
||||
index: str = Field(
|
||||
default="date",
|
||||
description='Index column name in ``data``, by default ``"date"``.',
|
||||
)
|
||||
anchor: str = Field(
|
||||
default="D",
|
||||
description=(
|
||||
"Resample anchor — pandas offset alias such as ``D``, ``W``, ``ME``. "
|
||||
"Determines the VWAP reset boundary."
|
||||
),
|
||||
)
|
||||
offset: int = Field(default=0, description="Periods to offset the result.")
|
||||
|
||||
|
||||
class VwapData(Data):
|
||||
"""One row of the Volume-Weighted Average Price time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
vwap : float, optional
|
||||
Volume-weighted average price at this bar, accumulated since the most
|
||||
recent anchor boundary.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
vwap: float | None = Field(description="Volume-weighted average price.")
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Daily-anchored VWAP on intraday TSLA.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').results",
|
||||
"vwap = obb.technical.vwap(data=data, anchor='D')",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def vwap(params: VwapQueryParams) -> OBBject[list[VwapData]]:
|
||||
"""Calculate the Volume-Weighted Average Price (VWAP), anchored at a chosen boundary."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
if params.index == "date":
|
||||
df.index = pd.to_datetime(df.index)
|
||||
series = df.ta.vwap(anchor=params.anchor, offset=params.offset)
|
||||
out = (
|
||||
pd.DataFrame({"vwap": series})
|
||||
.dropna()
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
return OBBject(results=[VwapData(**row) for row in out.to_dict(orient="records")])
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdData",
|
||||
"AdQueryParams",
|
||||
"AdoscData",
|
||||
"AdoscQueryParams",
|
||||
"ObvData",
|
||||
"ObvQueryParams",
|
||||
"VwapData",
|
||||
"VwapQueryParams",
|
||||
"ad",
|
||||
"adosc",
|
||||
"obv",
|
||||
"router",
|
||||
"vwap",
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Multi-indicator and cross-asset technical analysis endpoints."""
|
||||
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.multi.catalog import router as _catalog_router
|
||||
from openbb_technical.multi.compose import router as _compose_router
|
||||
from openbb_technical.multi.correlation import router as _correlation_router
|
||||
from openbb_technical.multi.screen import router as _screen_router
|
||||
|
||||
router = Router(prefix="", description="Multi-indicator / cross-asset endpoints.")
|
||||
router.include_router(_compose_router)
|
||||
router.include_router(_correlation_router)
|
||||
router.include_router(_screen_router)
|
||||
router.include_router(_catalog_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Introspected catalogue of every registered technical indicator."""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Any, Literal, get_args, get_origin, get_type_hints
|
||||
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
router = Router(prefix="", description="Indicator catalogue.")
|
||||
|
||||
|
||||
CatalogCategory = Literal[
|
||||
"overlay",
|
||||
"oscillator",
|
||||
"volatility",
|
||||
"volume",
|
||||
"trend",
|
||||
"signal",
|
||||
"structure",
|
||||
"stats",
|
||||
"multi",
|
||||
"all",
|
||||
]
|
||||
|
||||
|
||||
class IndicatorParam(Data):
|
||||
"""One input parameter of an indicator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Parameter name as accepted by the indicator endpoint.
|
||||
type : str
|
||||
Stable, human-readable rendering of the parameter's Python type
|
||||
annotation, e.g. ``"int"`` or ``"Literal['sma', 'ema']"``.
|
||||
default : Any, optional
|
||||
Default value applied when the caller omits the parameter. ``None``
|
||||
for required fields or when the default is not JSON-serialisable.
|
||||
description : str, optional
|
||||
Human-readable parameter description sourced from the Pydantic
|
||||
``Field`` description.
|
||||
choices : list[str], optional
|
||||
Allowed values when the parameter is a ``Literal``; ``None``
|
||||
otherwise.
|
||||
constraints : dict[str, Any], optional
|
||||
Pydantic numeric constraints (``gt``, ``ge``, ``lt``, ``le``,
|
||||
``multiple_of``, ``min_length``, ``max_length``) when present.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
description="Parameter name as accepted by the indicator endpoint."
|
||||
)
|
||||
type: str = Field(
|
||||
description="Stable, human-readable rendering of the parameter's Python type annotation, e.g. ``\"int\"`` or ``\"Literal['sma', 'ema']\"``."
|
||||
)
|
||||
default: Any | None = Field(
|
||||
default=None,
|
||||
description="Default value applied when the caller omits the parameter. ``None`` for required fields or when the default is not JSON-serialisable.",
|
||||
)
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable parameter description sourced from the Pydantic ``Field`` description.",
|
||||
)
|
||||
choices: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Allowed values when the parameter is a ``Literal``; ``None`` otherwise.",
|
||||
)
|
||||
constraints: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Pydantic numeric constraints (``gt``, ``ge``, ``lt``, ``le``, ``multiple_of``, ``min_length``, ``max_length``) when present.",
|
||||
)
|
||||
|
||||
|
||||
class IndicatorOutputColumn(Data):
|
||||
"""One output column of an indicator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Output column name.
|
||||
type : str
|
||||
Stable, human-readable rendering of the column's Python type
|
||||
annotation.
|
||||
nullable : bool
|
||||
``True`` when the column can produce ``None`` values (e.g. warm-up
|
||||
rows).
|
||||
description : str, optional
|
||||
Human-readable column description sourced from the Pydantic ``Field``
|
||||
description on the data model.
|
||||
"""
|
||||
|
||||
name: str = Field(description="Output column name.")
|
||||
type: str = Field(
|
||||
description="Stable, human-readable rendering of the column's Python type annotation."
|
||||
)
|
||||
nullable: bool = Field(
|
||||
description="``True`` when the column can produce ``None`` values (e.g. warm-up rows)."
|
||||
)
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable column description sourced from the Pydantic ``Field`` description on the data model.",
|
||||
)
|
||||
|
||||
|
||||
class IndicatorEntry(Data):
|
||||
"""Catalogue entry for a single indicator endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Endpoint name, matching the function name registered on the router.
|
||||
category : str
|
||||
Indicator family — e.g. ``"overlay"``, ``"oscillator"``,
|
||||
``"volatility"``, ``"multi"``.
|
||||
description : str, optional
|
||||
First line of the endpoint's QueryParams docstring.
|
||||
requires_columns : list[str]
|
||||
OHLCV columns the endpoint references in its docstring, in the
|
||||
canonical order ``open``, ``high``, ``low``, ``close``, ``volume``.
|
||||
params : list[IndicatorParam]
|
||||
Per-parameter metadata for the endpoint's QueryParams.
|
||||
output_columns : list[IndicatorOutputColumn]
|
||||
Per-column metadata for the endpoint's emitted data rows.
|
||||
example_call : dict[str, Any]
|
||||
Minimal example payload, populated from each parameter's default.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
description="Endpoint name, matching the function name registered on the router."
|
||||
)
|
||||
category: str = Field(
|
||||
description='Indicator family — e.g. ``"overlay"``, ``"oscillator"``, ``"volatility"``, ``"multi"``.'
|
||||
)
|
||||
description: str | None = Field(
|
||||
description="First line of the endpoint's QueryParams docstring."
|
||||
)
|
||||
requires_columns: list[str] = Field(
|
||||
description="OHLCV columns the endpoint references in its docstring, in the canonical order ``open``, ``high``, ``low``, ``close``, ``volume``."
|
||||
)
|
||||
params: list[IndicatorParam] = Field(
|
||||
description="Per-parameter metadata for the endpoint's QueryParams."
|
||||
)
|
||||
output_columns: list[IndicatorOutputColumn] = Field(
|
||||
description="Per-column metadata for the endpoint's emitted data rows."
|
||||
)
|
||||
example_call: dict[str, Any] = Field(
|
||||
description="Minimal example payload, populated from each parameter's default."
|
||||
)
|
||||
|
||||
|
||||
class IndicatorsQueryParams(QueryParams):
|
||||
"""Query parameters for the catalogue endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category : CatalogCategory, optional
|
||||
Filter to a single family — one of ``"overlay"``, ``"oscillator"``,
|
||||
``"volatility"``, ``"volume"``, ``"trend"``, ``"signal"``,
|
||||
``"structure"``, ``"stats"``, ``"multi"`` — or ``"all"`` (the
|
||||
default) to return every entry.
|
||||
"""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("indicators",)
|
||||
|
||||
category: CatalogCategory | None = Field(
|
||||
default="all",
|
||||
description="Filter to a single family, or ``all`` to return everything.",
|
||||
)
|
||||
|
||||
|
||||
class IndicatorsResponse(Data):
|
||||
"""Wrapper for the full catalogue response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicators : list[IndicatorEntry]
|
||||
Catalogue entries matching the requested category.
|
||||
"""
|
||||
|
||||
indicators: list[IndicatorEntry] = Field(
|
||||
description="Catalogue entries matching the requested category."
|
||||
)
|
||||
|
||||
|
||||
_OHLCV_COLUMNS = {"open", "high", "low", "close", "volume"}
|
||||
|
||||
|
||||
def _stringify_type(annotation: Any) -> str:
|
||||
"""Render a type annotation as a stable, human-readable string."""
|
||||
if annotation is inspect.Signature.empty or annotation is None:
|
||||
return "Any" # pragma: no cover - all our params are annotated
|
||||
return str(annotation).replace("typing.", "")
|
||||
|
||||
|
||||
def _literal_choices(annotation: Any) -> list[str] | None:
|
||||
"""Extract Literal[...] values, peeling away ``Optional`` / ``Annotated``."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is Literal:
|
||||
return [str(arg) for arg in get_args(annotation)]
|
||||
args = get_args(annotation)
|
||||
for arg in args:
|
||||
choices = _literal_choices(arg)
|
||||
if choices is not None:
|
||||
return choices
|
||||
return None
|
||||
|
||||
|
||||
def _field_constraints(field: FieldInfo) -> dict[str, Any] | None:
|
||||
"""Extract Pydantic numeric constraints (``gt``, ``ge``, ``lt``, ``le``)."""
|
||||
constraints: dict[str, Any] = {}
|
||||
for key in ("gt", "ge", "lt", "le", "multiple_of", "max_length", "min_length"):
|
||||
meta_value = getattr(field, key, None)
|
||||
if meta_value is not None:
|
||||
constraints[key] = meta_value
|
||||
for meta in getattr(field, "metadata", []) or []:
|
||||
for attr in ("gt", "ge", "lt", "le", "multiple_of"):
|
||||
if hasattr(meta, attr):
|
||||
constraints[attr] = getattr(meta, attr)
|
||||
return constraints or None
|
||||
|
||||
|
||||
def _default_for(field: FieldInfo) -> Any | None:
|
||||
"""Best-effort serialisable default for a Pydantic field."""
|
||||
if field.default_factory is not None: # type: ignore[truthy-function]
|
||||
try:
|
||||
return field.default_factory() # ty: ignore[missing-argument]
|
||||
except Exception: # pragma: no cover - factories are pure in practice
|
||||
return None
|
||||
if field.is_required():
|
||||
return None
|
||||
return field.default
|
||||
|
||||
|
||||
def _module_query_params(module: Any) -> dict[str, type[QueryParams]]:
|
||||
"""Find ``XxxQueryParams`` classes defined in or re-exported by ``module``."""
|
||||
out: dict[str, type[QueryParams]] = {}
|
||||
declared = set(getattr(module, "__all__", ()))
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if not (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, QueryParams)
|
||||
and attr is not QueryParams
|
||||
):
|
||||
continue
|
||||
# Accept classes defined in this module, or explicitly re-exported via
|
||||
# ``__all__`` (lets thin router shells reuse a QueryParams class
|
||||
# defined elsewhere in the package, e.g. ``relative_rotation``).
|
||||
if attr.__module__ == module.__name__ or attr_name in declared:
|
||||
out[attr_name] = attr
|
||||
return out
|
||||
|
||||
|
||||
def _match_query_params_for(
|
||||
endpoint_name: str, candidates: dict[str, type[QueryParams]]
|
||||
) -> type[QueryParams] | None:
|
||||
"""Match an endpoint name to its ``XxxQueryParams`` class by convention."""
|
||||
expected = endpoint_name.replace("_", "").lower() + "queryparams"
|
||||
for class_name, cls in candidates.items():
|
||||
if class_name.lower() == expected:
|
||||
return cls
|
||||
return None
|
||||
|
||||
|
||||
_FAMILY_MODULES: tuple[str, ...] = (
|
||||
"openbb_technical.indicators.overlays",
|
||||
"openbb_technical.indicators.oscillators",
|
||||
"openbb_technical.indicators.volatility",
|
||||
"openbb_technical.indicators.volume",
|
||||
"openbb_technical.indicators.trend",
|
||||
"openbb_technical.indicators.structure",
|
||||
"openbb_technical.indicators.statistics",
|
||||
"openbb_technical.indicators.relative_rotation",
|
||||
"openbb_technical.signals.breakouts",
|
||||
"openbb_technical.signals.crossovers",
|
||||
"openbb_technical.signals.divergences",
|
||||
"openbb_technical.signals.patterns",
|
||||
"openbb_technical.signals.regime",
|
||||
"openbb_technical.signals.thresholds",
|
||||
"openbb_technical.multi.compose",
|
||||
"openbb_technical.multi.correlation",
|
||||
"openbb_technical.multi.screen",
|
||||
"openbb_technical.multi.catalog",
|
||||
)
|
||||
|
||||
|
||||
def _required_data_columns(query_params: type[QueryParams]) -> list[str]:
|
||||
"""Report OHLCV columns referenced in the QueryParams docstring."""
|
||||
doc = (query_params.__doc__ or "").lower()
|
||||
found: list[str] = []
|
||||
for col in ("open", "high", "low", "close", "volume"):
|
||||
if col in doc and col not in found:
|
||||
found.append(col)
|
||||
return found
|
||||
|
||||
|
||||
def _example_call(
|
||||
endpoint_name: str, query_params: type[QueryParams]
|
||||
) -> dict[str, Any]:
|
||||
"""Build a minimal example payload using each field's default."""
|
||||
example: dict[str, Any] = {"endpoint": endpoint_name}
|
||||
for name, field in query_params.model_fields.items():
|
||||
if name == "data":
|
||||
example[name] = "<list[Data]>"
|
||||
continue
|
||||
default = _default_for(field)
|
||||
if default is not None:
|
||||
example[name] = default
|
||||
return example
|
||||
|
||||
|
||||
def _output_column_entries(
|
||||
query_params: type[QueryParams], data_model: type[Data] | None
|
||||
) -> list[IndicatorOutputColumn]:
|
||||
"""Build the per-column metadata for an indicator's output rows."""
|
||||
column_names: tuple[str, ...] = getattr(query_params, "__output_columns__", ())
|
||||
if not column_names:
|
||||
return [] # pragma: no cover - all migrated families declare columns
|
||||
type_map: dict[str, Any] = {}
|
||||
description_map: dict[str, str | None] = {}
|
||||
nullable_map: dict[str, bool] = {}
|
||||
if data_model is not None:
|
||||
hints = get_type_hints(data_model)
|
||||
for fname, field in data_model.model_fields.items():
|
||||
annotation = hints.get(fname, field.annotation)
|
||||
type_map[fname] = annotation
|
||||
description_map[fname] = field.description
|
||||
nullable_map[fname] = type(None) in get_args(annotation)
|
||||
entries: list[IndicatorOutputColumn] = []
|
||||
for col in column_names:
|
||||
annotation = type_map.get(col, str)
|
||||
entries.append(
|
||||
IndicatorOutputColumn(
|
||||
name=col,
|
||||
type=_stringify_type(annotation),
|
||||
nullable=nullable_map.get(col, False),
|
||||
description=description_map.get(col),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _find_data_model(module: Any, endpoint_name: str) -> type[Data] | None:
|
||||
"""Find the ``XxxData`` model paired with an endpoint, if any."""
|
||||
expected = endpoint_name.replace("_", "").lower() + "data"
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, Data)
|
||||
and attr is not Data
|
||||
and attr.__module__ == module.__name__
|
||||
and attr_name.lower() == expected
|
||||
):
|
||||
return attr
|
||||
return None
|
||||
|
||||
|
||||
def _build_catalog() -> list[IndicatorEntry]:
|
||||
"""Walk every known family module and collect catalogue entries."""
|
||||
entries: list[IndicatorEntry] = []
|
||||
for module_path in _FAMILY_MODULES:
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError: # pragma: no cover - family not yet migrated
|
||||
continue
|
||||
family_router = getattr(module, "router", None)
|
||||
if family_router is None:
|
||||
continue # pragma: no cover - every family module exposes ``router``
|
||||
candidates = _module_query_params(module)
|
||||
for route in family_router.api_router.routes:
|
||||
endpoint = route.endpoint
|
||||
endpoint_name = endpoint.__name__
|
||||
query_params = _match_query_params_for(endpoint_name, candidates)
|
||||
if query_params is None:
|
||||
continue
|
||||
data_model = _find_data_model(module, endpoint_name)
|
||||
params: list[IndicatorParam] = []
|
||||
for fname, field in query_params.model_fields.items():
|
||||
if fname == "data":
|
||||
continue
|
||||
annotation = field.annotation
|
||||
params.append(
|
||||
IndicatorParam(
|
||||
name=fname,
|
||||
type=_stringify_type(annotation),
|
||||
default=_default_for(field),
|
||||
description=field.description,
|
||||
choices=_literal_choices(annotation),
|
||||
constraints=_field_constraints(field),
|
||||
)
|
||||
)
|
||||
entries.append(
|
||||
IndicatorEntry(
|
||||
name=endpoint_name,
|
||||
category=getattr(query_params, "__category__", "uncategorized"),
|
||||
description=(query_params.__doc__ or "").strip() or None,
|
||||
requires_columns=_required_data_columns(query_params),
|
||||
params=params,
|
||||
output_columns=_output_column_entries(query_params, data_model),
|
||||
example_call=_example_call(endpoint_name, query_params),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
_CATALOG_CACHE: list[IndicatorEntry] | None = None
|
||||
|
||||
|
||||
def _catalog() -> list[IndicatorEntry]:
|
||||
"""Return the catalogue, building it lazily on the first call."""
|
||||
global _CATALOG_CACHE # noqa: PLW0603 - module-level lazy cache, no contention
|
||||
if _CATALOG_CACHE is None:
|
||||
_CATALOG_CACHE = _build_catalog()
|
||||
return _CATALOG_CACHE
|
||||
|
||||
|
||||
class _CatalogProxy:
|
||||
"""Lazy list proxy so legacy access patterns (``_CATALOG``) still work."""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(_catalog())
|
||||
|
||||
def __len__(self):
|
||||
return len(_catalog())
|
||||
|
||||
def __bool__(self):
|
||||
return bool(_catalog())
|
||||
|
||||
def __getitem__(self, item):
|
||||
return _catalog()[item]
|
||||
|
||||
|
||||
_CATALOG: _CatalogProxy = _CatalogProxy()
|
||||
|
||||
|
||||
@router.command(methods=["POST"])
|
||||
def indicators(params: IndicatorsQueryParams) -> OBBject[IndicatorsResponse]:
|
||||
"""Return the catalogue of registered technical indicators."""
|
||||
full = _catalog()
|
||||
if params.category in (None, "all"):
|
||||
selected = list(full)
|
||||
else:
|
||||
selected = [e for e in full if e.category == params.category]
|
||||
return OBBject(results=IndicatorsResponse(indicators=selected))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CatalogCategory",
|
||||
"IndicatorEntry",
|
||||
"IndicatorOutputColumn",
|
||||
"IndicatorParam",
|
||||
"IndicatorsQueryParams",
|
||||
"IndicatorsResponse",
|
||||
"indicators",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Multi-indicator composition endpoint."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Compose multiple indicators on one series.")
|
||||
|
||||
|
||||
_PREFERRED_MODULES: tuple[str, ...] = (
|
||||
"openbb_technical.indicators.overlays",
|
||||
"openbb_technical.indicators.oscillators",
|
||||
"openbb_technical.indicators.volatility",
|
||||
"openbb_technical.indicators.volume",
|
||||
"openbb_technical.indicators.trend",
|
||||
"openbb_technical.indicators.structure",
|
||||
"openbb_technical.indicators.statistics",
|
||||
"openbb_technical.signals.breakouts",
|
||||
"openbb_technical.signals.crossovers",
|
||||
"openbb_technical.signals.divergences",
|
||||
"openbb_technical.signals.patterns",
|
||||
"openbb_technical.signals.regime",
|
||||
"openbb_technical.signals.thresholds",
|
||||
"openbb_technical.technical_router",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_indicator(name: str) -> Callable[..., Any] | None:
|
||||
"""Look up an indicator endpoint by name across the known modules."""
|
||||
for module_path in _PREFERRED_MODULES:
|
||||
try:
|
||||
module = import_module(module_path)
|
||||
except ImportError: # pragma: no cover - sibling family absent
|
||||
continue
|
||||
fn = getattr(module, name, None)
|
||||
if callable(fn):
|
||||
return fn
|
||||
return None # pragma: no cover - guarded at the caller
|
||||
|
||||
|
||||
def _call_indicator(fn: Callable[..., Any], **kwargs: Any) -> Any:
|
||||
"""Invoke an indicator endpoint, building its QueryParams model from kwargs.
|
||||
|
||||
Every router endpoint takes a single ``params: XxxQueryParams`` argument.
|
||||
The model class is read from the function signature and instantiated from
|
||||
``kwargs``; any kwarg the model does not declare (e.g. ``target`` for an
|
||||
endpoint with no target field) is dropped so construction does not fail.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
qp_class = next(iter(inspect.signature(fn).parameters.values())).annotation
|
||||
accepted = {k: v for k, v in kwargs.items() if k in qp_class.model_fields}
|
||||
return fn(qp_class(**accepted))
|
||||
|
||||
|
||||
class MultiIndicatorRequest(QueryParams):
|
||||
"""Describe one indicator the caller wants computed.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator : str
|
||||
Indicator endpoint name. Must match a registered router endpoint, e.g.
|
||||
``rsi`` or ``bbands``.
|
||||
params : dict[str, float | int | str], optional
|
||||
Keyword arguments forwarded to the indicator endpoint. Empty by default.
|
||||
"""
|
||||
|
||||
indicator: str = Field(
|
||||
description="Indicator name. Must match a registered router endpoint, e.g. ``rsi`` or ``bbands``.",
|
||||
)
|
||||
params: dict[str, float | int | str] = Field(
|
||||
default_factory=dict,
|
||||
description="Keyword arguments forwarded to the indicator endpoint.",
|
||||
)
|
||||
|
||||
|
||||
class MultiQueryParams(QueryParams):
|
||||
"""Query parameters for the multi-indicator composition endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input OHLC(V) price series, shared by every requested indicator.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
indicators : list[MultiIndicatorRequest]
|
||||
Indicators to compute, in arbitrary order. Each entry pairs an
|
||||
endpoint name with its keyword arguments.
|
||||
target : str, optional
|
||||
Target column forwarded to indicators that accept one, by default
|
||||
``"close"``.
|
||||
"""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("date", "values")
|
||||
|
||||
data: list[Data] = Field(description="Input OHLC(V) series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
indicators: list[MultiIndicatorRequest] = Field(
|
||||
description="Indicators to compute, in arbitrary order.",
|
||||
)
|
||||
target: str = Field(
|
||||
default="close",
|
||||
description="Target column forwarded to indicators that accept one.",
|
||||
)
|
||||
|
||||
|
||||
class MultiResultRow(Data):
|
||||
"""One merged row across every requested indicator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date for the merged row.
|
||||
values : dict[str, float | None]
|
||||
Indicator outputs for this date, keyed as ``"<indicator>.<column>"``.
|
||||
Only numeric columns are retained; non-numeric outputs are skipped.
|
||||
``None`` values mark warm-up rows where the indicator has not yet
|
||||
produced a finite reading.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
values: dict[str, float | None] = Field(
|
||||
description=(
|
||||
"Indicator outputs for this date keyed as "
|
||||
'``"<indicator>.<column>"``. Numeric columns only — non-'
|
||||
"numeric outputs are skipped."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _to_records(obbject: Any) -> list[dict[str, Any]]:
|
||||
"""Coerce an OBBject's ``results`` into a list of plain dicts."""
|
||||
results = getattr(obbject, "results", obbject)
|
||||
if results is None: # pragma: no cover - defensive
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in results:
|
||||
if hasattr(row, "model_dump"):
|
||||
out.append(row.model_dump())
|
||||
elif isinstance(row, dict):
|
||||
out.append(row)
|
||||
else: # pragma: no cover - unexpected row shape
|
||||
out.append(dict(row))
|
||||
return out
|
||||
|
||||
|
||||
def _row_key(row: dict[str, Any], index: str) -> Any:
|
||||
"""Best-effort extraction of the date key for merging."""
|
||||
if index in row:
|
||||
return row[index]
|
||||
if "date" in row: # pragma: no cover - alias kept for safety
|
||||
return row["date"]
|
||||
return None # pragma: no cover - endpoint without a date column
|
||||
|
||||
|
||||
_SENTINEL_COLUMNS = {"date", "open", "high", "low", "close", "volume", "adj_close"}
|
||||
|
||||
|
||||
def _numeric_columns(row: dict[str, Any], target: str) -> dict[str, float | None]:
|
||||
"""Keep only numeric columns that aren't part of the input OHLCV bar."""
|
||||
out: dict[str, float | None] = {}
|
||||
sentinels = _SENTINEL_COLUMNS | {target}
|
||||
for key, value in row.items():
|
||||
if key in sentinels:
|
||||
continue
|
||||
if value is None:
|
||||
out[key] = None
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
continue # pragma: no cover - indicators don't emit bools today
|
||||
if isinstance(value, (int, float)):
|
||||
out[key] = float(value)
|
||||
return out
|
||||
|
||||
|
||||
@router.command(methods=["POST"])
|
||||
def multi(params: MultiQueryParams) -> OBBject[list[MultiResultRow]]:
|
||||
"""Run multiple indicators on a single price series and merge them by date."""
|
||||
|
||||
merged: dict[Any, dict[str, float | None]] = {}
|
||||
|
||||
for request in params.indicators:
|
||||
fn = _resolve_indicator(request.indicator)
|
||||
if fn is None:
|
||||
continue
|
||||
result = _call_indicator(
|
||||
fn,
|
||||
data=params.data,
|
||||
index=params.index,
|
||||
target=params.target,
|
||||
**request.params,
|
||||
)
|
||||
rows = _to_records(result)
|
||||
for row in rows:
|
||||
key = _row_key(row, params.index)
|
||||
if key is None: # pragma: no cover - defensive
|
||||
continue
|
||||
numeric = _numeric_columns(row, params.target)
|
||||
bucket = merged.setdefault(key, {})
|
||||
for col, value in numeric.items():
|
||||
bucket[f"{request.indicator}.{col}"] = value
|
||||
|
||||
ordered_keys = sorted(merged.keys(), key=str)
|
||||
out_rows = [
|
||||
MultiResultRow(date=key, values=merged[key]) # ty: ignore[invalid-argument-type]
|
||||
for key in ordered_keys
|
||||
]
|
||||
return OBBject(results=out_rows)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MultiIndicatorRequest",
|
||||
"MultiQueryParams",
|
||||
"MultiResultRow",
|
||||
"multi",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Pairwise rolling correlation and snapshot correlation matrix."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from itertools import combinations
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Cross-asset correlation endpoints.")
|
||||
|
||||
|
||||
class CorrelationQueryParams(QueryParams):
|
||||
"""Query parameters for the rolling pairwise correlation endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Long-format multi-symbol price data. Must contain a ``symbol`` column
|
||||
and the chosen ``target`` price column.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to compute correlation on, by default ``"close"``. Pairwise
|
||||
returns are derived from ``pct_change`` on this column.
|
||||
window : PositiveInt, optional
|
||||
Rolling window length in bars, by default 60.
|
||||
method : {"pearson", "spearman", "kendall"}, optional
|
||||
Correlation method passed through to ``pandas.DataFrame.corr``,
|
||||
by default ``"pearson"``.
|
||||
pairs : list[tuple[str, str]], optional
|
||||
Explicit list of ``(symbol_a, symbol_b)`` pairs. ``None`` (the
|
||||
default) enumerates every unique pair from the input symbols.
|
||||
"""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("date", "symbol_a", "symbol_b", "correlation")
|
||||
|
||||
data: list[Data] = Field(
|
||||
description="Long-format multi-symbol price data with a ``symbol`` column.",
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(
|
||||
default="close",
|
||||
description="Column to compute correlation on.",
|
||||
)
|
||||
window: PositiveInt = Field(
|
||||
default=60,
|
||||
description="Rolling window length in bars.",
|
||||
)
|
||||
method: Literal["pearson", "spearman", "kendall"] = Field(
|
||||
default="pearson",
|
||||
description="Correlation method passed through to ``pandas.DataFrame.corr``.",
|
||||
)
|
||||
pairs: list[tuple[str, str]] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional explicit list of ``(symbol_a, symbol_b)`` pairs. "
|
||||
"Defaults to all unique pairs."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CorrelationData(Data):
|
||||
"""One row of the rolling-correlation time series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
symbol_a : str
|
||||
First symbol of the pair.
|
||||
symbol_b : str
|
||||
Second symbol of the pair.
|
||||
correlation : float, optional
|
||||
Rolling correlation between ``symbol_a`` and ``symbol_b`` over the
|
||||
trailing ``window`` bars. ``None`` for warm-up rows where the window
|
||||
has not yet filled or where either series has a missing return.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
symbol_a: str = Field(description="First symbol of the pair.")
|
||||
symbol_b: str = Field(description="Second symbol of the pair.")
|
||||
correlation: float | None = Field(
|
||||
description="Rolling correlation between ``symbol_a`` and ``symbol_b``.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(methods=["POST"])
|
||||
def correlation(params: CorrelationQueryParams) -> OBBject[list[CorrelationData]]:
|
||||
"""Compute rolling pairwise correlation of returns across symbols."""
|
||||
import math
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
if "symbol" not in df.columns:
|
||||
raise ValueError("Input data must contain a 'symbol' column.")
|
||||
wide = df.pivot_table(
|
||||
index=df.index,
|
||||
columns="symbol",
|
||||
values=params.target,
|
||||
aggfunc="last",
|
||||
).sort_index()
|
||||
returns = wide.pct_change()
|
||||
|
||||
available = list(wide.columns)
|
||||
if params.pairs is None:
|
||||
pair_list = list(combinations(available, 2))
|
||||
else:
|
||||
pair_list = [
|
||||
(a, b) for (a, b) in params.pairs if a in available and b in available
|
||||
]
|
||||
|
||||
import pandas as pd
|
||||
|
||||
rows: list[CorrelationData] = []
|
||||
for symbol_a, symbol_b in pair_list:
|
||||
sa = returns[symbol_a]
|
||||
sb = returns[symbol_b]
|
||||
if params.method == "pearson":
|
||||
series = sa.rolling(window=params.window).corr(sb)
|
||||
else:
|
||||
values: list[float] = []
|
||||
arr_a = sa.to_numpy()
|
||||
arr_b = sb.to_numpy()
|
||||
n = len(arr_a)
|
||||
for end in range(n):
|
||||
start = end - params.window + 1
|
||||
if start < 0:
|
||||
values.append(float("nan"))
|
||||
continue
|
||||
window_a = pd.Series(arr_a[start : end + 1])
|
||||
window_b = pd.Series(arr_b[start : end + 1])
|
||||
if window_a.isna().any() or window_b.isna().any():
|
||||
values.append(float("nan"))
|
||||
continue
|
||||
values.append(float(window_a.corr(window_b, method=params.method)))
|
||||
series = pd.Series(values, index=returns.index, dtype=float)
|
||||
for date_value, corr_value in series.items():
|
||||
value: float | None
|
||||
if corr_value is None or (
|
||||
isinstance(corr_value, float) and math.isnan(corr_value)
|
||||
):
|
||||
value = None
|
||||
else:
|
||||
value = float(corr_value)
|
||||
rows.append(
|
||||
CorrelationData(
|
||||
date=date_value, # ty: ignore[invalid-argument-type]
|
||||
symbol_a=symbol_a,
|
||||
symbol_b=symbol_b,
|
||||
correlation=value,
|
||||
)
|
||||
)
|
||||
return OBBject(results=rows)
|
||||
|
||||
|
||||
class CorrelationMatrixQueryParams(QueryParams):
|
||||
"""Query parameters for the snapshot correlation-matrix endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Long-format multi-symbol price data. Must contain a ``symbol`` column
|
||||
and the chosen ``target`` price column.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Column to correlate, by default ``"close"``.
|
||||
window : PositiveInt, optional
|
||||
If set, restrict the correlation calculation to the trailing
|
||||
``window`` rows preceding ``as_of_date``. ``None`` (the default) uses
|
||||
every available row up to and including the anchor.
|
||||
method : {"pearson", "spearman", "kendall"}, optional
|
||||
Correlation method, by default ``"pearson"``.
|
||||
as_of_date : date | str, optional
|
||||
Anchor date for the snapshot. ``None`` (the default) uses the most
|
||||
recent row in the input.
|
||||
"""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("as_of_date", "symbols", "matrix")
|
||||
|
||||
data: list[Data] = Field(
|
||||
description="Long-format multi-symbol price data with a ``symbol`` column.",
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Column to correlate.")
|
||||
window: PositiveInt | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"If set, restrict correlation to the trailing ``window`` rows "
|
||||
"before ``as_of_date``. ``None`` uses every available row."
|
||||
),
|
||||
)
|
||||
method: Literal["pearson", "spearman", "kendall"] = Field(
|
||||
default="pearson", description='Correlation method, by default ``"pearson"``.'
|
||||
)
|
||||
as_of_date: datetime | dateType | str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Anchor date for the snapshot. ``None`` uses the last available "
|
||||
"row in the input."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CorrelationMatrixData(Data):
|
||||
"""A square correlation matrix at a single point in time.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
as_of_date : date | str
|
||||
Anchor date used to compute the snapshot.
|
||||
symbols : list[str]
|
||||
Symbols, in matrix order. ``matrix[i][j]`` is the correlation between
|
||||
``symbols[i]`` and ``symbols[j]``.
|
||||
matrix : list[list[float]]
|
||||
Square correlation matrix, with diagonal elements equal to 1 and the
|
||||
matrix symmetric about the diagonal.
|
||||
"""
|
||||
|
||||
as_of_date: datetime | dateType | str = Field(
|
||||
description="Anchor date used for the snapshot."
|
||||
)
|
||||
symbols: list[str] = Field(description="Symbols, in matrix order.")
|
||||
matrix: list[list[float]] = Field(description="Correlation values.")
|
||||
|
||||
|
||||
@router.command(methods=["POST"])
|
||||
def correlation_matrix(
|
||||
params: CorrelationMatrixQueryParams,
|
||||
) -> OBBject[list[CorrelationMatrixData]]:
|
||||
"""Build a point-in-time correlation matrix across every symbol in the input."""
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
if "symbol" not in df.columns:
|
||||
raise ValueError("Input data must contain a 'symbol' column.")
|
||||
wide = df.pivot_table(
|
||||
index=df.index,
|
||||
columns="symbol",
|
||||
values=params.target,
|
||||
aggfunc="last",
|
||||
).sort_index()
|
||||
returns = wide.pct_change().dropna(how="all")
|
||||
|
||||
if params.as_of_date is None:
|
||||
anchor = returns.index.max()
|
||||
else:
|
||||
anchor_ts = pd.to_datetime(params.as_of_date)
|
||||
anchor = anchor_ts.date() if hasattr(anchor_ts, "date") else anchor_ts
|
||||
|
||||
sliced = returns.loc[[idx_val <= anchor for idx_val in returns.index]]
|
||||
if params.window is not None:
|
||||
sliced = sliced.tail(params.window)
|
||||
|
||||
corr_df = sliced.corr(method=params.method)
|
||||
symbols = list(corr_df.columns)
|
||||
|
||||
matrix: list[list[float]] = []
|
||||
for row_label in symbols:
|
||||
row: list[float] = []
|
||||
for col_label in symbols:
|
||||
value = corr_df.at[row_label, col_label]
|
||||
if value is None or (isinstance(value, float) and math.isnan(value)):
|
||||
row.append(float("nan")) # pragma: no cover - all-aligned input
|
||||
else:
|
||||
row.append(float(value))
|
||||
matrix.append(row)
|
||||
|
||||
as_of_out: datetime | dateType | str
|
||||
if hasattr(anchor, "date") and callable(
|
||||
anchor.date
|
||||
): # pragma: no cover - object-index path is used in practice
|
||||
as_of_out = anchor.date() # ty: ignore[invalid-assignment, call-top-callable]
|
||||
elif isinstance(anchor, date):
|
||||
as_of_out = anchor
|
||||
else: # pragma: no cover - unexpected anchor type
|
||||
as_of_out = str(anchor)
|
||||
|
||||
return OBBject(
|
||||
results=[
|
||||
CorrelationMatrixData(
|
||||
as_of_date=as_of_out,
|
||||
symbols=symbols,
|
||||
matrix=matrix,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CorrelationData",
|
||||
"CorrelationMatrixData",
|
||||
"CorrelationMatrixQueryParams",
|
||||
"CorrelationQueryParams",
|
||||
"correlation",
|
||||
"correlation_matrix",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Indicator-based screening across a basket of symbols."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Any, Literal
|
||||
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df, df_to_basemodel
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field
|
||||
|
||||
from openbb_technical.multi.compose import (
|
||||
_call_indicator,
|
||||
_resolve_indicator,
|
||||
_to_records,
|
||||
)
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Indicator-based screening.")
|
||||
|
||||
|
||||
Operator = Literal[
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"eq",
|
||||
"between",
|
||||
"crossed_above",
|
||||
"crossed_below",
|
||||
"made_high",
|
||||
"made_low",
|
||||
]
|
||||
|
||||
|
||||
class ScreenCondition(QueryParams):
|
||||
"""Describe one screening predicate over a single indicator output column.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
indicator : str
|
||||
Indicator endpoint name, e.g. ``rsi`` or ``macd``.
|
||||
column : str
|
||||
Column to read from the indicator's output, e.g. ``close_RSI_14``. The
|
||||
column name is matched case-insensitively against the indicator's
|
||||
emitted columns.
|
||||
operator : Operator
|
||||
Comparison or event operator. Scalar comparisons (``gt``, ``gte``,
|
||||
``lt``, ``lte``, ``eq``) take a single threshold; ``between`` takes a
|
||||
``(low, high)`` tuple; event operators (``crossed_above``,
|
||||
``crossed_below``, ``made_high``, ``made_low``) take a
|
||||
``(threshold, lookback_bars)`` tuple.
|
||||
value : float | tuple[float, float]
|
||||
Operator argument — a scalar threshold or a two-element tuple
|
||||
depending on ``operator`` (see above).
|
||||
indicator_params : dict[str, float | int | str], optional
|
||||
Keyword arguments forwarded to the indicator endpoint. Empty by
|
||||
default.
|
||||
"""
|
||||
|
||||
indicator: str = Field(description="Indicator endpoint name, e.g. ``rsi``.")
|
||||
column: str = Field(
|
||||
description=(
|
||||
"Column to read from the indicator's output, e.g. "
|
||||
"``close_RSI_14``. The column name is matched case-insensitively."
|
||||
),
|
||||
)
|
||||
operator: Operator = Field(description="Comparison or event operator.")
|
||||
value: float | tuple[float, float] = Field(
|
||||
description=(
|
||||
"Threshold for comparison operators, ``(low, high)`` tuple for "
|
||||
"``between``, or ``(threshold, lookback_bars)`` tuple for event "
|
||||
"operators (``crossed_*``/``made_*``)."
|
||||
),
|
||||
)
|
||||
indicator_params: dict[str, float | int | str] = Field(
|
||||
default_factory=dict,
|
||||
description="Keyword arguments forwarded to the indicator endpoint.",
|
||||
)
|
||||
|
||||
|
||||
class ScreenQueryParams(QueryParams):
|
||||
"""Query parameters for the multi-symbol screen endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Long-format multi-symbol data with a ``symbol`` column. Every group
|
||||
is evaluated independently.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
target : str, optional
|
||||
Default target column forwarded to indicators that accept one, by
|
||||
default ``"close"``.
|
||||
conditions : list[ScreenCondition]
|
||||
Predicates to evaluate against each symbol's computed indicators.
|
||||
combine : {"and", "or"}, optional
|
||||
``"and"`` (default) returns symbols matching every condition;
|
||||
``"or"`` returns symbols matching at least one condition.
|
||||
as_of_date : date | str, optional
|
||||
Anchor date for evaluation. ``None`` (default) uses each symbol's
|
||||
most recent row.
|
||||
"""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("symbol", "as_of_date", "matched_conditions", "values")
|
||||
|
||||
data: list[Data] = Field(
|
||||
description="Long-format multi-symbol data with a ``symbol`` column.",
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
target: str = Field(default="close", description="Default target column.")
|
||||
conditions: list[ScreenCondition] = Field(
|
||||
description="Predicates to evaluate against each symbol's indicators.",
|
||||
)
|
||||
combine: Literal["and", "or"] = Field(
|
||||
default="and",
|
||||
description=(
|
||||
"``and`` returns symbols matching every condition; ``or`` returns "
|
||||
"symbols matching at least one."
|
||||
),
|
||||
)
|
||||
as_of_date: datetime | dateType | str | None = Field(
|
||||
default=None,
|
||||
description=("Anchor date. Defaults to the latest date per symbol when unset."),
|
||||
)
|
||||
|
||||
|
||||
class ScreenMatch(Data):
|
||||
"""One screened symbol that satisfied the configured conditions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbol : str
|
||||
Symbol that matched.
|
||||
as_of_date : date | str
|
||||
Date used to evaluate the predicates for this symbol.
|
||||
matched_conditions : int
|
||||
Number of conditions that fired for this symbol.
|
||||
values : dict[str, float]
|
||||
Latest indicator values that contributed to the match, keyed as
|
||||
``"<indicator>.<column>"``.
|
||||
"""
|
||||
|
||||
symbol: str = Field(description="Symbol that matched.")
|
||||
as_of_date: datetime | dateType | str = Field(
|
||||
description="Date used for evaluation."
|
||||
)
|
||||
matched_conditions: int = Field(description="How many conditions fired.")
|
||||
values: dict[str, float] = Field(
|
||||
description=(
|
||||
"Latest indicator values that contributed to the match, keyed by "
|
||||
'``"<indicator>.<column>"``.'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_EVENT_OPS = {"crossed_above", "crossed_below", "made_high", "made_low"}
|
||||
|
||||
|
||||
def _normalise_value(value: Any) -> tuple[float, float] | float:
|
||||
"""Coerce list-style inputs to tuples for the tuple-valued operators."""
|
||||
if isinstance(value, list):
|
||||
if len(value) != 2: # pragma: no cover - schema validates length
|
||||
raise ValueError("Tuple-valued conditions require exactly two elements.")
|
||||
return (float(value[0]), float(value[1]))
|
||||
if isinstance(value, tuple):
|
||||
return (float(value[0]), float(value[1]))
|
||||
return float(value)
|
||||
|
||||
|
||||
def _find_column(row: dict[str, Any], column: str) -> str | None:
|
||||
"""Case-insensitive lookup for the requested indicator column."""
|
||||
if column in row:
|
||||
return column
|
||||
lowered = column.lower()
|
||||
for key in row:
|
||||
if isinstance(key, str) and key.lower() == lowered:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _evaluate(
|
||||
series: list[tuple[Any, float | None]],
|
||||
operator: Operator,
|
||||
value: tuple[float, float] | float,
|
||||
anchor_index: int,
|
||||
) -> tuple[bool, float | None]:
|
||||
"""Run ``operator`` against the indicator series at ``anchor_index``."""
|
||||
if anchor_index < 0 or anchor_index >= len(series):
|
||||
return False, None # pragma: no cover - defensive
|
||||
_, latest = series[anchor_index]
|
||||
if latest is None:
|
||||
return False, None
|
||||
|
||||
if operator == "gt":
|
||||
return latest > float(value), latest # ty: ignore[invalid-argument-type]
|
||||
if operator == "gte":
|
||||
return latest >= float(value), latest # ty: ignore[invalid-argument-type]
|
||||
if operator == "lt":
|
||||
return latest < float(value), latest # ty: ignore[invalid-argument-type]
|
||||
if operator == "lte":
|
||||
return latest <= float(value), latest # ty: ignore[invalid-argument-type]
|
||||
if operator == "eq":
|
||||
return latest == float(value), latest # ty: ignore[invalid-argument-type]
|
||||
if operator == "between":
|
||||
low, high = value # ty: ignore[not-iterable]
|
||||
return low <= latest <= high, latest
|
||||
|
||||
threshold, lookback_raw = value # ty: ignore[not-iterable]
|
||||
lookback = int(lookback_raw)
|
||||
start = max(0, anchor_index - lookback + 1)
|
||||
window = [v for _, v in series[start : anchor_index + 1] if v is not None]
|
||||
if len(window) < 2:
|
||||
return False, latest
|
||||
|
||||
if operator == "crossed_above":
|
||||
fired = any(
|
||||
a <= threshold < b for a, b in zip(window[:-1], window[1:], strict=False)
|
||||
)
|
||||
return fired, latest
|
||||
if operator == "crossed_below":
|
||||
fired = any(
|
||||
a >= threshold > b for a, b in zip(window[:-1], window[1:], strict=False)
|
||||
)
|
||||
return fired, latest
|
||||
if operator == "made_high":
|
||||
fired = latest >= max(window)
|
||||
return fired, latest
|
||||
fired = latest <= min(window)
|
||||
return fired, latest
|
||||
|
||||
|
||||
@router.command(methods=["POST"])
|
||||
def screen(params: ScreenQueryParams) -> OBBject[list[ScreenMatch]]:
|
||||
"""Filter a multi-symbol basket by indicator-driven conditions."""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
if "symbol" not in df.columns:
|
||||
raise ValueError("Input data must contain a 'symbol' column.")
|
||||
|
||||
anchor: Any | None
|
||||
if params.as_of_date is None:
|
||||
anchor = None
|
||||
else:
|
||||
anchor_ts = pd.to_datetime(params.as_of_date)
|
||||
anchor = anchor_ts.date() if hasattr(anchor_ts, "date") else anchor_ts
|
||||
|
||||
matches: list[ScreenMatch] = []
|
||||
|
||||
for symbol, group in df.groupby("symbol"):
|
||||
symbol_df = group.drop(columns=["symbol"]).copy()
|
||||
if symbol_df.empty:
|
||||
continue # pragma: no cover - groupby never yields empty groups
|
||||
if anchor is not None:
|
||||
mask = [idx_val <= anchor for idx_val in symbol_df.index]
|
||||
symbol_df = symbol_df.loc[mask]
|
||||
if symbol_df.empty:
|
||||
continue
|
||||
symbol_records = df_to_basemodel(symbol_df.reset_index())
|
||||
|
||||
fired_count = 0
|
||||
captured: dict[str, float] = {}
|
||||
any_skipped = False
|
||||
|
||||
for condition in params.conditions:
|
||||
fn = _resolve_indicator(condition.indicator)
|
||||
if fn is None:
|
||||
any_skipped = True
|
||||
continue
|
||||
indicator_result = _call_indicator(
|
||||
fn,
|
||||
data=symbol_records,
|
||||
index=params.index,
|
||||
target=params.target,
|
||||
**condition.indicator_params,
|
||||
)
|
||||
rows = _to_records(indicator_result)
|
||||
if not rows:
|
||||
continue # pragma: no cover - indicator returned nothing
|
||||
col_name = _find_column(rows[0], condition.column)
|
||||
if col_name is None:
|
||||
continue
|
||||
series = [(row.get(params.index), row.get(col_name)) for row in rows]
|
||||
anchor_index = len(series) - 1
|
||||
normalised = _normalise_value(condition.value)
|
||||
fired, observed = _evaluate(
|
||||
series, condition.operator, normalised, anchor_index
|
||||
)
|
||||
if fired:
|
||||
fired_count += 1
|
||||
if observed is not None:
|
||||
captured[f"{condition.indicator}.{col_name}"] = float(observed)
|
||||
|
||||
if any_skipped and fired_count == 0:
|
||||
continue # pragma: no cover - every condition skipped
|
||||
|
||||
required = len(params.conditions) if params.combine == "and" else 1
|
||||
if fired_count >= required:
|
||||
as_of_for_row: date | str
|
||||
last_index = symbol_df.index.max()
|
||||
if hasattr(last_index, "date") and callable(last_index.date):
|
||||
as_of_for_row = (
|
||||
last_index.date()
|
||||
) # pragma: no cover - object index path used in practice
|
||||
elif isinstance(last_index, date):
|
||||
as_of_for_row = last_index
|
||||
else: # pragma: no cover - unexpected index type
|
||||
as_of_for_row = str(last_index)
|
||||
matches.append(
|
||||
ScreenMatch(
|
||||
symbol=str(symbol),
|
||||
as_of_date=as_of_for_row,
|
||||
matched_conditions=fired_count,
|
||||
values=captured,
|
||||
)
|
||||
)
|
||||
return OBBject(results=matches)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Operator",
|
||||
"ScreenCondition",
|
||||
"ScreenMatch",
|
||||
"ScreenQueryParams",
|
||||
"screen",
|
||||
"router",
|
||||
]
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Relative Rotation Model."""
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-instance-attributes, protected-access
|
||||
# pylint: disable=too-many-locals, too-few-public-methods, unused-argument
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
@@ -86,7 +83,6 @@ def standard_deviation(
|
||||
pd.DataFrame : results
|
||||
Dataframe with results.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log, sqrt
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -130,14 +126,13 @@ def calculate_momentum(
|
||||
Series
|
||||
Pandas Series with the calculated momentum.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from numpy import log
|
||||
|
||||
df = data.copy()
|
||||
epsilon = 1e-10
|
||||
momentum_long = log(1 + df.pct_change(long_period) + epsilon)
|
||||
momentum_short = log(1 + df.pct_change(short_period) + epsilon)
|
||||
data = momentum_long - momentum_short # type: ignore
|
||||
data = momentum_long - momentum_short
|
||||
|
||||
return data
|
||||
|
||||
@@ -164,13 +159,14 @@ def get_momentum(
|
||||
DataFrame
|
||||
Pandas DataFrame with the calculated historical momentum factor exposure score.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from pandas import DataFrame
|
||||
|
||||
df = data.copy()
|
||||
rs_momentum = DataFrame()
|
||||
for ticker in df.columns.to_list():
|
||||
rs_momentum.loc[:, ticker] = calculate_momentum(df.loc[:, ticker], long_period, short_period) # type: ignore
|
||||
rs_momentum.loc[:, ticker] = calculate_momentum(
|
||||
df.loc[:, ticker], long_period, short_period
|
||||
)
|
||||
|
||||
return rs_momentum
|
||||
|
||||
@@ -242,7 +238,7 @@ def process_data(
|
||||
class RelativeRotation:
|
||||
"""Relative Rotation Class."""
|
||||
|
||||
def __init__( # pylint: disable=R0917
|
||||
def __init__(
|
||||
self,
|
||||
data: Union[list[Data], "DataFrame"],
|
||||
benchmark: str,
|
||||
@@ -253,7 +249,6 @@ class RelativeRotation:
|
||||
trading_periods: int | None = 252,
|
||||
):
|
||||
"""Initialize the class."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import contextlib # noqa
|
||||
from openbb_core.app.model.obbject import OBBject # noqa
|
||||
from openbb_core.app.utils import ( # noqa
|
||||
@@ -269,7 +264,7 @@ class RelativeRotation:
|
||||
target_col = "volume" if study == "volume" else "close"
|
||||
|
||||
if isinstance(data, OBBject):
|
||||
data = data.results # type: ignore
|
||||
data = data.results
|
||||
|
||||
if isinstance(data, list) and (
|
||||
all(isinstance(d, Data) for d in data)
|
||||
@@ -278,10 +273,14 @@ class RelativeRotation:
|
||||
with contextlib.suppress(Exception):
|
||||
df = basemodel_to_df(convert_to_basemodel(data), index="date")
|
||||
|
||||
if isinstance(data, DataFrame) and not df.empty:
|
||||
df = data.copy()
|
||||
if "date" in df.columns:
|
||||
df.set_index("date", inplace=True)
|
||||
if (
|
||||
isinstance(data, DataFrame) and not df.empty
|
||||
): # pragma: no cover - unreachable: the only path that pre-populates ``df`` requires ``data`` to be ``list`` (the prior branch), which makes ``isinstance(data, DataFrame)`` False. The condition is preserved from upstream as a defensive guard.
|
||||
df = data.copy() # pragma: no cover - body of pragma'd branch
|
||||
if "date" in df.columns: # pragma: no cover - body of pragma'd branch
|
||||
df.set_index(
|
||||
"date", inplace=True
|
||||
) # pragma: no cover - body of pragma'd branch
|
||||
|
||||
if df.empty:
|
||||
raise ValueError(
|
||||
@@ -297,13 +296,13 @@ class RelativeRotation:
|
||||
benchmark_data = df.pop(benchmark).to_frame()
|
||||
symbols_data = df
|
||||
|
||||
if len(symbols_data) <= 252 and study in ["price", "volume"]: # type: ignore
|
||||
if len(symbols_data) <= 252 and study in ["price", "volume"]:
|
||||
raise ValueError(
|
||||
"Supplied data must be daily intervals and have more than one year of back data to calculate"
|
||||
" the most recent day in the time series."
|
||||
)
|
||||
|
||||
if study == "volatility" and len(symbols_data) <= 504: # type: ignore
|
||||
if study == "volatility" and len(symbols_data) <= 504:
|
||||
raise ValueError(
|
||||
"Supplied data must be daily intervals and have more than two years of back data to calculate"
|
||||
" the most recent day in the time series as a volatility study."
|
||||
@@ -315,15 +314,14 @@ class RelativeRotation:
|
||||
self.short_period = short_period
|
||||
self.window = window
|
||||
self.trading_periods = trading_periods
|
||||
self.symbols_data = symbols_data # type: ignore
|
||||
self.benchmark_data = benchmark_data # type: ignore
|
||||
self._process_data() # type: ignore
|
||||
self.symbols_data = df_to_basemodel(self.symbols_data.reset_index()) # type: ignore
|
||||
self.benchmark_data = df_to_basemodel(self.benchmark_data.reset_index()) # type: ignore
|
||||
self.symbols_data = symbols_data
|
||||
self.benchmark_data = benchmark_data
|
||||
self._process_data()
|
||||
self.symbols_data = df_to_basemodel(self.symbols_data.reset_index())
|
||||
self.benchmark_data = df_to_basemodel(self.benchmark_data.reset_index())
|
||||
|
||||
def _process_data(self):
|
||||
"""Process the data."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
from pandas import to_datetime
|
||||
|
||||
@@ -344,7 +342,6 @@ class RelativeRotation:
|
||||
long_period=self.long_period, # type: ignore
|
||||
short_period=self.short_period, # type: ignore
|
||||
)
|
||||
# Re-index rs_ratios using the new index
|
||||
index_after_dropping_nans = momentum.dropna().index
|
||||
ratios = ratios.reindex(index_after_dropping_nans)
|
||||
self.rs_ratios = df_to_basemodel(ratios.reset_index())
|
||||
@@ -359,13 +356,17 @@ def _get_type_name(t):
|
||||
if hasattr(t, "__origin__"):
|
||||
if hasattr(t.__origin__, "__name__"):
|
||||
return f"{t.__origin__.__name__}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]"
|
||||
if hasattr(t.__origin__, "_name"):
|
||||
if hasattr(
|
||||
t.__origin__, "_name"
|
||||
): # pragma: no cover - dead on supported Python (>=3.10): generic ``__origin__`` is always a runtime class with ``__name__``
|
||||
return f"{t.__origin__._name}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]"
|
||||
if isinstance(t, str):
|
||||
return t
|
||||
if hasattr(t, "__name__"):
|
||||
return t.__name__
|
||||
if hasattr(t, "_name"):
|
||||
if hasattr(
|
||||
t, "_name"
|
||||
): # pragma: no cover - dead on supported Python (>=3.10): typing constructs reaching this branch always also expose ``__name__``
|
||||
return t._name
|
||||
return str(t)
|
||||
|
||||
@@ -373,6 +374,9 @@ def _get_type_name(t):
|
||||
class RelativeRotationQueryParams(QueryParams):
|
||||
"""Relative Rotation Query Parameters."""
|
||||
|
||||
__category__ = "multi"
|
||||
__output_columns__ = ("symbols", "benchmark", "study", "rs_ratios", "rs_momentum")
|
||||
|
||||
data: list[Data] = Field(
|
||||
description="The data to be used for the relative rotation calculations."
|
||||
+ " This should be the multi-symbol output from the"
|
||||
@@ -441,7 +445,6 @@ class RelativeRotationQueryParams(QueryParams):
|
||||
@classmethod
|
||||
def convert_data(cls, v):
|
||||
"""Validate the data format."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.utils import convert_to_basemodel, df_to_basemodel
|
||||
from pandas import DataFrame
|
||||
@@ -550,7 +553,7 @@ class RelativeRotationFetcher(
|
||||
@staticmethod
|
||||
def transform_query(params: dict[str, Any]) -> RelativeRotationQueryParams:
|
||||
"""Transform the query parameters."""
|
||||
return RelativeRotationQueryParams.model_validate(**params)
|
||||
return RelativeRotationQueryParams.model_validate(params)
|
||||
|
||||
@staticmethod
|
||||
def extract_data(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Top-level router for the openbb-technical extension."""
|
||||
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.indicators import router as _indicators_router
|
||||
from openbb_technical.multi import router as _multi_router
|
||||
from openbb_technical.signals import router as _signals_router
|
||||
|
||||
router = Router(prefix="", description="Technical Analysis tools.")
|
||||
router.include_router(_indicators_router)
|
||||
router.include_router(_signals_router)
|
||||
router.include_router(_multi_router)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Signals sub-package — derived events on top of raw indicators."""
|
||||
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.signals import (
|
||||
breakouts,
|
||||
crossovers,
|
||||
divergences,
|
||||
patterns,
|
||||
regime,
|
||||
thresholds,
|
||||
)
|
||||
|
||||
router = Router(
|
||||
prefix="/signals", description="Signal-layer endpoints on technical indicators."
|
||||
)
|
||||
router.include_router(crossovers.router)
|
||||
router.include_router(thresholds.router)
|
||||
router.include_router(divergences.router)
|
||||
router.include_router(breakouts.router)
|
||||
router.include_router(patterns.router)
|
||||
router.include_router(regime.router)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"breakouts",
|
||||
"crossovers",
|
||||
"divergences",
|
||||
"patterns",
|
||||
"regime",
|
||||
"router",
|
||||
"thresholds",
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Channel-breakout signals."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveFloat, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Channel-breakout signals.")
|
||||
|
||||
|
||||
BreakoutMethod = Literal["donchian", "bollinger"]
|
||||
|
||||
|
||||
class BreakoutsQueryParams(QueryParams):
|
||||
"""Query parameters for the channel-breakouts endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
method : {"donchian", "bollinger"}, optional
|
||||
Channel definition. ``donchian`` uses the rolling high/low envelope;
|
||||
``bollinger`` uses the rolling mean plus or minus ``band_std``
|
||||
standard deviations. Default is ``"donchian"``.
|
||||
length : PositiveInt, optional
|
||||
Lookback window for the channel, by default 20.
|
||||
band_std : PositiveFloat, optional
|
||||
Bollinger band multiplier. Ignored for ``donchian``. ``None``
|
||||
resolves to 2.0 when ``method='bollinger'``.
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"direction",
|
||||
"price",
|
||||
"band",
|
||||
"magnitude",
|
||||
"bars_in_range",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input OHLC price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
method: BreakoutMethod = Field(
|
||||
default="donchian",
|
||||
description=(
|
||||
"Channel definition: Donchian (rolling high/low) or Bollinger "
|
||||
"(mean +/- ``band_std`` * stdev)."
|
||||
),
|
||||
)
|
||||
length: PositiveInt = Field(
|
||||
default=20, description="Lookback window for the channel."
|
||||
)
|
||||
band_std: PositiveFloat | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Bollinger band multiplier. Ignored for ``donchian``. ``None`` "
|
||||
"resolves to 2.0 when ``method='bollinger'``."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BreakoutEvent(Data):
|
||||
"""One row per breakout bar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Bar on which the breakout occurred.
|
||||
direction : {"upside", "downside"}
|
||||
Which band was crossed.
|
||||
price : float
|
||||
Close price on the breakout bar.
|
||||
band : float
|
||||
Previous bar's band level that was breached.
|
||||
magnitude : float
|
||||
Signed distance ``price - band``. Positive for upside breakouts;
|
||||
negative for downside breakouts.
|
||||
bars_in_range : int
|
||||
Bars elapsed since the most recent breakout in either direction.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(
|
||||
description="Bar on which the breakout occurred."
|
||||
)
|
||||
direction: Literal["upside", "downside"] = Field(
|
||||
description="Which band was crossed.",
|
||||
)
|
||||
price: float = Field(description="Close price on the breakout bar.")
|
||||
band: float = Field(description="Previous bar's band level that was breached.")
|
||||
magnitude: float = Field(
|
||||
description="Signed distance: ``price - band``. Positive for upside, negative for downside.",
|
||||
)
|
||||
bars_in_range: int = Field(
|
||||
description="Bars since the most recent breakout (in either direction).",
|
||||
)
|
||||
|
||||
|
||||
def _channel_bands(df, method: str, length: int, band_std: float | None):
|
||||
"""Return ``(upper_band, lower_band)`` Series aligned to ``df.index``."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
if method == "donchian":
|
||||
dc = df.ta.donchian(lower_length=length, upper_length=length)
|
||||
upper = dc[f"DCU_{length}_{length}"]
|
||||
lower = dc[f"DCL_{length}_{length}"]
|
||||
return upper, lower
|
||||
std = band_std if band_std is not None else 2.0
|
||||
bb = df.ta.bbands(length=length, std=std)
|
||||
bbu_col = next(c for c in bb.columns if c.startswith("BBU_"))
|
||||
bbl_col = next(c for c in bb.columns if c.startswith("BBL_"))
|
||||
return bb[bbu_col], bb[bbl_col]
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
APIEx(
|
||||
parameters={
|
||||
"data": APIEx.mock_data("timeseries"),
|
||||
"method": "donchian",
|
||||
"length": 20,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def breakouts(params: BreakoutsQueryParams) -> OBBject[list[BreakoutEvent]]:
|
||||
"""Detect channel breakouts where price closes beyond a rolling envelope."""
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
|
||||
upper, lower = _channel_bands(df, params.method, params.length, params.band_std)
|
||||
prev_upper = upper.shift(1)
|
||||
prev_lower = lower.shift(1)
|
||||
close = df["close"].astype(float)
|
||||
|
||||
up_break = close > prev_upper
|
||||
dn_break = close < prev_lower
|
||||
|
||||
events: list[dict] = []
|
||||
last_event_pos: int | None = None
|
||||
dates = df.index.tolist()
|
||||
|
||||
for i, dt in enumerate(dates):
|
||||
is_up = bool(up_break.iloc[i])
|
||||
is_dn = bool(dn_break.iloc[i])
|
||||
if not is_up and not is_dn:
|
||||
continue
|
||||
direction = "upside" if is_up else "downside"
|
||||
band = float(prev_upper.iloc[i] if is_up else prev_lower.iloc[i])
|
||||
price = float(close.iloc[i])
|
||||
magnitude = price - band
|
||||
bars_in_range = i if last_event_pos is None else i - last_event_pos
|
||||
events.append(
|
||||
{
|
||||
"date": dt,
|
||||
"direction": direction,
|
||||
"price": price,
|
||||
"band": band,
|
||||
"magnitude": magnitude,
|
||||
"bars_in_range": int(bars_in_range),
|
||||
}
|
||||
)
|
||||
last_event_pos = i
|
||||
|
||||
return OBBject(results=[BreakoutEvent(**row) for row in events])
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BreakoutEvent",
|
||||
"BreakoutMethod",
|
||||
"BreakoutsQueryParams",
|
||||
"breakouts",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Moving-average crossover signal."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt, field_validator
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Signal-style technical indicators.")
|
||||
|
||||
|
||||
MaMode = Literal["sma", "ema", "wma", "hma", "zlma"]
|
||||
|
||||
|
||||
class CrossoversQueryParams(QueryParams):
|
||||
"""Query parameters for the moving-average crossover endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input price series containing ``target``.
|
||||
target : str, optional
|
||||
Column on which to compute the moving averages, by default ``"close"``.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
fast_length : PositiveInt, optional
|
||||
Lookback for the fast moving average, by default 20.
|
||||
slow_length : PositiveInt, optional
|
||||
Lookback for the slow moving average. Must be strictly greater than
|
||||
``fast_length``, by default 50.
|
||||
mamode : {"sma", "ema", "wma", "hma", "zlma"}, optional
|
||||
Moving-average flavour, by default ``"sma"``.
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"direction",
|
||||
"fast_value",
|
||||
"slow_value",
|
||||
"price",
|
||||
"distance",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input price series.")
|
||||
target: str = Field(
|
||||
default="close", description="Column on which to compute moving averages."
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
fast_length: PositiveInt = Field(
|
||||
default=20, description="Lookback for the fast moving average."
|
||||
)
|
||||
slow_length: PositiveInt = Field(
|
||||
default=50, description="Lookback for the slow moving average."
|
||||
)
|
||||
mamode: MaMode = Field(
|
||||
default="sma",
|
||||
description="Moving-average flavour. ``sma`` is the default; ``ema``, ``wma``, ``hma`` and ``zlma`` are also supported.",
|
||||
)
|
||||
|
||||
@field_validator("slow_length")
|
||||
@classmethod
|
||||
def slow_must_exceed_fast(cls, v: int, info) -> int:
|
||||
"""Enforce ``slow_length > fast_length``."""
|
||||
fast = info.data.get("fast_length")
|
||||
if fast is not None and v <= fast:
|
||||
raise ValueError("slow_length must be greater than fast_length.")
|
||||
return v
|
||||
|
||||
|
||||
class CrossoverEvent(Data):
|
||||
"""One row per moving-average crossover bar (sparse output).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Date of the crossover bar.
|
||||
direction : {"bullish", "bearish"}
|
||||
``"bullish"`` when the fast average crosses above the slow average;
|
||||
``"bearish"`` when the fast average crosses below the slow average.
|
||||
fast_value : float
|
||||
Fast moving-average value at the crossover bar.
|
||||
slow_value : float
|
||||
Slow moving-average value at the crossover bar.
|
||||
price : float
|
||||
Target-column price at the crossover bar.
|
||||
distance : float
|
||||
Signed gap ``fast_value - slow_value``. Positive for bullish crossovers
|
||||
and negative for bearish crossovers.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Date of the crossover bar.")
|
||||
direction: Literal["bullish", "bearish"] = Field(
|
||||
description="``bullish`` when fast crosses above slow, ``bearish`` when fast crosses below.",
|
||||
)
|
||||
fast_value: float = Field(
|
||||
description="Fast moving-average value at the crossover bar."
|
||||
)
|
||||
slow_value: float = Field(
|
||||
description="Slow moving-average value at the crossover bar."
|
||||
)
|
||||
price: float = Field(description="Target-column price at the crossover bar.")
|
||||
distance: float = Field(
|
||||
description="``fast_value - slow_value``. Positive for bullish, negative for bearish crossovers.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="20/50 SMA crossover events on daily SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"events = obb.technical.crossovers(data=data, fast_length=20, slow_length=50)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def crossovers(params: CrossoversQueryParams) -> OBBject[list[CrossoverEvent]]:
|
||||
"""Detect bullish and bearish moving-average crossover events."""
|
||||
import numpy as np
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.slow_length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
series = df[params.target]
|
||||
fast = getattr(series.to_frame(name=params.target).ta, params.mamode)(
|
||||
length=params.fast_length
|
||||
)
|
||||
slow = getattr(series.to_frame(name=params.target).ta, params.mamode)(
|
||||
length=params.slow_length
|
||||
)
|
||||
diff = (fast - slow).dropna()
|
||||
if diff.empty:
|
||||
return OBBject(results=[])
|
||||
sign = np.sign(diff)
|
||||
prev = sign.shift(1)
|
||||
flips = (sign != prev) & (sign != 0) & (prev != 0) & (sign * prev < 0)
|
||||
events: list[CrossoverEvent] = []
|
||||
for ts in diff.index[flips.fillna(False)]:
|
||||
d = float(diff.loc[ts])
|
||||
events.append(
|
||||
CrossoverEvent(
|
||||
date=ts,
|
||||
direction="bullish" if d > 0 else "bearish",
|
||||
fast_value=float(fast.loc[ts]),
|
||||
slow_value=float(slow.loc[ts]),
|
||||
price=float(series.loc[ts]),
|
||||
distance=d,
|
||||
)
|
||||
)
|
||||
return OBBject(results=events)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CrossoverEvent",
|
||||
"CrossoversQueryParams",
|
||||
"MaMode",
|
||||
"crossovers",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Price/indicator divergence detection."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Price/indicator divergence signals.")
|
||||
|
||||
|
||||
Indicator = Literal["rsi", "macd", "stoch", "cci"]
|
||||
DivergenceKind = Literal[
|
||||
"regular_bullish",
|
||||
"hidden_bullish",
|
||||
"regular_bearish",
|
||||
"hidden_bearish",
|
||||
]
|
||||
|
||||
|
||||
class DivergencesQueryParams(QueryParams):
|
||||
"""Query parameters for the divergence-detection endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
indicator : {"rsi", "macd", "stoch", "cci"}
|
||||
Oscillator paired with price for divergence detection.
|
||||
indicator_length : PositiveInt, optional
|
||||
Lookback length used inside the oscillator, by default 14.
|
||||
target : str, optional
|
||||
Price column used for swing detection, by default ``"close"``.
|
||||
lookback : PositiveInt, optional
|
||||
Number of recent bars in which to search for swing pairs, by
|
||||
default 60.
|
||||
min_swing_distance : PositiveInt, optional
|
||||
Minimum number of bars separating two swing points. Also doubles as
|
||||
the half-window for local-extreme detection, by default 5.
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = (
|
||||
"confirmation_date",
|
||||
"prior_swing_date",
|
||||
"kind",
|
||||
"price_at_prior",
|
||||
"price_at_confirmation",
|
||||
"indicator_at_prior",
|
||||
"indicator_at_confirmation",
|
||||
"strength",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
indicator: Indicator = Field(
|
||||
description="Oscillator paired with price for divergence detection.",
|
||||
)
|
||||
indicator_length: PositiveInt = Field(
|
||||
default=14,
|
||||
description="Lookback length used inside the oscillator.",
|
||||
)
|
||||
target: str = Field(default="close", description="Price column used for swings.")
|
||||
lookback: PositiveInt = Field(
|
||||
default=60,
|
||||
description="Number of recent bars in which to search for swing pairs.",
|
||||
)
|
||||
min_swing_distance: PositiveInt = Field(
|
||||
default=5,
|
||||
description=(
|
||||
"Minimum number of bars separating two swing points. Also doubles "
|
||||
"as the half-window for local-extreme detection."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DivergenceEvent(Data):
|
||||
"""One row per confirmed price/indicator divergence.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
confirmation_date : date | str
|
||||
Bar of the later (confirming) swing.
|
||||
prior_swing_date : date | str
|
||||
Bar of the earlier swing in the pair.
|
||||
kind : {"regular_bullish", "hidden_bullish", "regular_bearish", "hidden_bearish"}
|
||||
``regular_*`` flags reversal signals; ``hidden_*`` flags
|
||||
trend-continuation signals.
|
||||
price_at_prior : float
|
||||
Target-column price at ``prior_swing_date``.
|
||||
price_at_confirmation : float
|
||||
Target-column price at ``confirmation_date``.
|
||||
indicator_at_prior : float
|
||||
Oscillator value at ``prior_swing_date``.
|
||||
indicator_at_confirmation : float
|
||||
Oscillator value at ``confirmation_date``.
|
||||
strength : float
|
||||
0-1 score combining the relative price and indicator displacements.
|
||||
Higher means a wider, more emphatic divergence.
|
||||
"""
|
||||
|
||||
confirmation_date: datetime | dateType | str = Field(
|
||||
description="Bar of the later (confirming) swing.",
|
||||
)
|
||||
prior_swing_date: datetime | dateType | str = Field(
|
||||
description="Bar of the earlier swing in the pair.",
|
||||
)
|
||||
kind: DivergenceKind = Field(
|
||||
description=(
|
||||
"``regular_bullish`` / ``regular_bearish`` flag reversal signals; "
|
||||
"``hidden_*`` flag trend-continuation signals."
|
||||
),
|
||||
)
|
||||
price_at_prior: float = Field(
|
||||
description="Target-column price at ``prior_swing_date``."
|
||||
)
|
||||
price_at_confirmation: float = Field(
|
||||
description="Target-column price at ``confirmation_date``.",
|
||||
)
|
||||
indicator_at_prior: float = Field(
|
||||
description="Oscillator value at ``prior_swing_date``.",
|
||||
)
|
||||
indicator_at_confirmation: float = Field(
|
||||
description="Oscillator value at ``confirmation_date``.",
|
||||
)
|
||||
strength: float = Field(
|
||||
description=(
|
||||
"0-1 score combining the relative price and indicator displacements. "
|
||||
"Higher means a wider, more emphatic divergence."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _compute_indicator(df, indicator: Indicator, length: int, target: str):
|
||||
"""Return a single 1-D oscillator series aligned to ``df``'s index."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
series = df[target]
|
||||
if indicator == "rsi":
|
||||
return series.to_frame(name=target).ta.rsi(length=length, close=target)
|
||||
if indicator == "cci":
|
||||
return df.ta.cci(length=length)
|
||||
if indicator == "stoch":
|
||||
result = df.ta.stoch(k=length, d=3, smooth_k=3)
|
||||
return result.iloc[:, 0]
|
||||
macd = series.to_frame(name=target).ta.macd(
|
||||
fast=12, slow=26, signal=9, close=target
|
||||
)
|
||||
return macd.iloc[:, 1]
|
||||
|
||||
|
||||
def _local_extrema(values, distance: int) -> tuple[list[int], list[int]]:
|
||||
"""Return positional indices of strict local maxima and minima."""
|
||||
n = len(values)
|
||||
highs: list[int] = []
|
||||
lows: list[int] = []
|
||||
for i in range(distance, n - distance):
|
||||
v = values[i]
|
||||
window = values[i - distance : i + distance + 1]
|
||||
if v == max(window) and sum(1 for w in window if w == v) == 1:
|
||||
highs.append(i)
|
||||
if v == min(window) and sum(1 for w in window if w == v) == 1:
|
||||
lows.append(i)
|
||||
return highs, lows
|
||||
|
||||
|
||||
def _classify_pair(
|
||||
prior_price: float,
|
||||
later_price: float,
|
||||
prior_ind: float,
|
||||
later_ind: float,
|
||||
is_high: bool,
|
||||
) -> DivergenceKind | None:
|
||||
"""Classify a swing pair as one of the four divergence flavours."""
|
||||
if is_high:
|
||||
if later_price > prior_price and later_ind < prior_ind:
|
||||
return "regular_bearish"
|
||||
if later_price < prior_price and later_ind > prior_ind:
|
||||
return "hidden_bearish"
|
||||
return None
|
||||
if later_price < prior_price and later_ind > prior_ind:
|
||||
return "regular_bullish"
|
||||
if later_price > prior_price and later_ind < prior_ind:
|
||||
return "hidden_bullish"
|
||||
return None
|
||||
|
||||
|
||||
def _strength(
|
||||
prior_price: float,
|
||||
later_price: float,
|
||||
prior_ind: float,
|
||||
later_ind: float,
|
||||
) -> float:
|
||||
"""Return a normalised composite of price and indicator displacement."""
|
||||
price_scale = max(abs(prior_price), abs(later_price), 1e-12)
|
||||
ind_scale = max(abs(prior_ind), abs(later_ind), 1e-12)
|
||||
price_delta = abs(later_price - prior_price) / price_scale
|
||||
ind_delta = abs(later_ind - prior_ind) / ind_scale
|
||||
return float(max(0.0, min(1.0, (price_delta + ind_delta) / 2.0)))
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="RSI divergences on daily SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"events = obb.technical.divergences(data=data, indicator='rsi')",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries"), "indicator": "rsi"}),
|
||||
],
|
||||
)
|
||||
def divergences(params: DivergencesQueryParams) -> OBBject[list[DivergenceEvent]]:
|
||||
"""Detect regular and hidden divergences between price swings and an oscillator."""
|
||||
validate_data(params.data, [params.indicator_length, params.min_swing_distance])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
ind_series = _compute_indicator(
|
||||
df, params.indicator, params.indicator_length, params.target
|
||||
)
|
||||
|
||||
window = df.tail(params.lookback)
|
||||
ind_window = ind_series.reindex(window.index)
|
||||
|
||||
prices = window[params.target].tolist()
|
||||
inds = ind_window.tolist()
|
||||
timestamps = list(window.index)
|
||||
|
||||
import math
|
||||
|
||||
valid_idx = [i for i, v in enumerate(inds) if not math.isnan(v)]
|
||||
if len(valid_idx) <= 2 * params.min_swing_distance:
|
||||
return OBBject(results=[])
|
||||
|
||||
valid_set = set(valid_idx)
|
||||
price_highs, price_lows = _local_extrema(prices, params.min_swing_distance)
|
||||
price_highs = [i for i in price_highs if i in valid_set]
|
||||
price_lows = [i for i in price_lows if i in valid_set]
|
||||
|
||||
events: list[DivergenceEvent] = []
|
||||
|
||||
def _emit_pairs(swing_indices: list[int], is_high: bool) -> None:
|
||||
for a, b in zip(swing_indices, swing_indices[1:]):
|
||||
# Unreachable: ``_local_extrema`` enforces gap > distance for
|
||||
# any two strict extrema returned for the same kind, and
|
||||
# ``distance`` is ``min_swing_distance``. Kept as a defensive
|
||||
# guard for future callers that bypass ``_local_extrema``.
|
||||
if b - a < params.min_swing_distance: # pragma: no cover
|
||||
continue
|
||||
kind = _classify_pair(
|
||||
prior_price=prices[a],
|
||||
later_price=prices[b],
|
||||
prior_ind=inds[a],
|
||||
later_ind=inds[b],
|
||||
is_high=is_high,
|
||||
)
|
||||
if kind is None:
|
||||
continue
|
||||
events.append(
|
||||
DivergenceEvent(
|
||||
confirmation_date=timestamps[b],
|
||||
prior_swing_date=timestamps[a],
|
||||
kind=kind,
|
||||
price_at_prior=float(prices[a]),
|
||||
price_at_confirmation=float(prices[b]),
|
||||
indicator_at_prior=float(inds[a]),
|
||||
indicator_at_confirmation=float(inds[b]),
|
||||
strength=_strength(prices[a], prices[b], inds[a], inds[b]),
|
||||
)
|
||||
)
|
||||
|
||||
_emit_pairs(price_highs, is_high=True)
|
||||
_emit_pairs(price_lows, is_high=False)
|
||||
events.sort(key=lambda e: (e.confirmation_date, e.kind))
|
||||
return OBBject(results=events)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DivergenceEvent",
|
||||
"DivergenceKind",
|
||||
"DivergencesQueryParams",
|
||||
"Indicator",
|
||||
"divergences",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Candlestick-pattern detection."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Candlestick-pattern signals.")
|
||||
|
||||
|
||||
SUPPORTED_PATTERNS: tuple[str, ...] = (
|
||||
"doji",
|
||||
"hammer",
|
||||
"engulfing",
|
||||
"marubozu",
|
||||
"spinning_top",
|
||||
)
|
||||
|
||||
|
||||
Direction = Literal["bullish", "bearish", "neutral"]
|
||||
|
||||
|
||||
class CandlestickPatternsQueryParams(QueryParams):
|
||||
"""Query parameters for the candlestick-patterns endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
patterns : list[str], optional
|
||||
Subset of patterns to scan for. ``None`` runs every entry in
|
||||
``SUPPORTED_PATTERNS``.
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = ("date", "pattern", "direction", "confidence")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
patterns: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Subset of patterns to scan for. ``None`` runs every entry in "
|
||||
"``SUPPORTED_PATTERNS``."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PatternEvent(Data):
|
||||
"""One row per confirmed pattern bar (sparse output).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Date of the pattern bar.
|
||||
pattern : str
|
||||
Pattern name in lower-snake-case.
|
||||
direction : {"bullish", "bearish", "neutral"}
|
||||
Bias implied by the pattern.
|
||||
confidence : float
|
||||
Heuristic 0-1 score derived from body and shadow ratios. Higher means
|
||||
the bar matches the textbook pattern more cleanly.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Date of the pattern bar.")
|
||||
pattern: str = Field(description="Pattern name, lower-snake-case.")
|
||||
direction: Direction = Field(
|
||||
description="Bias implied by the pattern: ``bullish``, ``bearish`` or ``neutral``.",
|
||||
)
|
||||
confidence: float = Field(
|
||||
description=(
|
||||
"Heuristic 0-1 score derived from body/shadow ratios. Higher "
|
||||
"means the bar matches the textbook pattern more cleanly."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _body_and_range(o: float, h: float, l: float, c: float) -> tuple[float, float]:
|
||||
"""Return ``(body, range)`` for one OHLC bar."""
|
||||
body = abs(c - o)
|
||||
rng = h - l
|
||||
return body, rng
|
||||
|
||||
|
||||
def _detect_doji(row) -> tuple[Direction, float] | None:
|
||||
"""Detect a Doji: tiny body relative to total range, signalling indecision."""
|
||||
body, rng = _body_and_range(row["open"], row["high"], row["low"], row["close"])
|
||||
if rng <= 0:
|
||||
return None
|
||||
ratio = body / rng
|
||||
if ratio > 0.1:
|
||||
return None
|
||||
return "neutral", float(1.0 - ratio * 10.0)
|
||||
|
||||
|
||||
def _detect_hammer(row) -> tuple[Direction, float] | None:
|
||||
"""Detect a Hammer: small body in the upper half with a long lower shadow."""
|
||||
o, h, l, c = row["open"], row["high"], row["low"], row["close"]
|
||||
body, rng = _body_and_range(o, h, l, c)
|
||||
if rng <= 0 or body <= 0:
|
||||
return None
|
||||
upper = h - max(o, c)
|
||||
lower = min(o, c) - l
|
||||
if lower < 2.0 * body:
|
||||
return None
|
||||
if upper > body:
|
||||
return None
|
||||
body_mid = (o + c) / 2.0
|
||||
if (body_mid - l) / rng < 2.0 / 3.0:
|
||||
return None
|
||||
return "bullish", float(min(1.0, lower / (3.0 * body)))
|
||||
|
||||
|
||||
def _detect_marubozu(row) -> tuple[Direction, float] | None:
|
||||
"""Detect a Marubozu: full body with near-zero upper and lower shadows."""
|
||||
o, h, l, c = row["open"], row["high"], row["low"], row["close"]
|
||||
body, rng = _body_and_range(o, h, l, c)
|
||||
if rng <= 0 or body <= 0:
|
||||
return None
|
||||
upper = h - max(o, c)
|
||||
lower = min(o, c) - l
|
||||
if body / rng < 0.95:
|
||||
return None
|
||||
# Unreachable: ``rng = body + upper + lower``, so ``body/rng >= 0.95``
|
||||
# bounds the combined shadows at 5% — neither alone can exceed it.
|
||||
if upper / rng > 0.05 or lower / rng > 0.05: # pragma: no cover
|
||||
return None
|
||||
direction: Direction = "bullish" if c > o else "bearish"
|
||||
return direction, float(body / rng)
|
||||
|
||||
|
||||
def _detect_spinning_top(row) -> tuple[Direction, float] | None:
|
||||
"""Detect a Spinning Top: small body with comparable upper and lower shadows."""
|
||||
o, h, l, c = row["open"], row["high"], row["low"], row["close"]
|
||||
body, rng = _body_and_range(o, h, l, c)
|
||||
if rng <= 0:
|
||||
return None
|
||||
upper = h - max(o, c)
|
||||
lower = min(o, c) - l
|
||||
if body / rng > 0.3 or body / rng < 0.05:
|
||||
return None
|
||||
if upper <= 0 or lower <= 0:
|
||||
return None
|
||||
larger = max(upper, lower)
|
||||
smaller = min(upper, lower)
|
||||
if smaller / larger < 0.5:
|
||||
return None
|
||||
return "neutral", float(smaller / larger)
|
||||
|
||||
|
||||
def _detect_engulfing(row, prev) -> tuple[Direction, float] | None:
|
||||
"""Detect an Engulfing bar: current real body fully engulfs the previous."""
|
||||
if prev is None:
|
||||
return None
|
||||
o, c = row["open"], row["close"]
|
||||
po, pc = prev["open"], prev["close"]
|
||||
body = abs(c - o)
|
||||
prev_body = abs(pc - po)
|
||||
if body <= 0 or prev_body <= 0:
|
||||
return None
|
||||
cur_top = max(o, c)
|
||||
cur_bot = min(o, c)
|
||||
prev_top = max(po, pc)
|
||||
prev_bot = min(po, pc)
|
||||
if not (cur_top >= prev_top and cur_bot <= prev_bot):
|
||||
return None
|
||||
if c > o and pc < po:
|
||||
return "bullish", float(min(1.0, body / (prev_body * 2.0)))
|
||||
if c < o and pc > po:
|
||||
return "bearish", float(min(1.0, body / (prev_body * 2.0)))
|
||||
return None
|
||||
|
||||
|
||||
_DETECTORS = {
|
||||
"doji": _detect_doji,
|
||||
"hammer": _detect_hammer,
|
||||
"marubozu": _detect_marubozu,
|
||||
"spinning_top": _detect_spinning_top,
|
||||
"engulfing": _detect_engulfing,
|
||||
}
|
||||
|
||||
_TWO_BAR = {"engulfing"}
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Scan all supported patterns on daily SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"patterns = obb.technical.candlestick_patterns(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def candlestick_patterns(
|
||||
params: CandlestickPatternsQueryParams,
|
||||
) -> OBBject[list[PatternEvent]]:
|
||||
"""Scan OHLC bars for classical candlestick patterns and emit one row per match."""
|
||||
requested = params.patterns or list(SUPPORTED_PATTERNS)
|
||||
requested = [p for p in requested if p in SUPPORTED_PATTERNS]
|
||||
if not requested:
|
||||
return OBBject(results=[])
|
||||
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
|
||||
events: list[PatternEvent] = []
|
||||
prev_row = None
|
||||
for ts, row in df.iterrows():
|
||||
for name in requested:
|
||||
detector = _DETECTORS[name]
|
||||
hit = (
|
||||
detector(row, prev_row) # ty: ignore[too-many-positional-arguments]
|
||||
if name in _TWO_BAR
|
||||
else detector(row) # ty: ignore[missing-argument]
|
||||
)
|
||||
if hit is None:
|
||||
continue
|
||||
direction, confidence = hit
|
||||
events.append(
|
||||
PatternEvent(
|
||||
date=ts, # ty: ignore[invalid-argument-type]
|
||||
pattern=name,
|
||||
direction=direction,
|
||||
confidence=float(max(0.0, min(1.0, confidence))),
|
||||
)
|
||||
)
|
||||
prev_row = row
|
||||
return OBBject(results=events)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CandlestickPatternsQueryParams",
|
||||
"Direction",
|
||||
"PatternEvent",
|
||||
"SUPPORTED_PATTERNS",
|
||||
"candlestick_patterns",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Market-regime classification."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx, PythonEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveFloat, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Market-regime classification.")
|
||||
|
||||
|
||||
Regime = Literal["strong_trend", "weak_trend", "ranging", "transition"]
|
||||
|
||||
|
||||
def _classify(
|
||||
adx: float | None,
|
||||
chop: float | None,
|
||||
adx_threshold: float,
|
||||
chop_threshold: float,
|
||||
) -> Regime:
|
||||
"""Map a single bar's ADX and Choppiness pair to a regime label."""
|
||||
if adx is None or chop is None:
|
||||
return "transition"
|
||||
if adx > adx_threshold and chop < chop_threshold:
|
||||
return "strong_trend"
|
||||
if adx > adx_threshold / 2.0:
|
||||
return "weak_trend"
|
||||
if chop > chop_threshold:
|
||||
return "ranging"
|
||||
return "transition"
|
||||
|
||||
|
||||
class RegimeQueryParams(QueryParams):
|
||||
"""Query parameters for the regime-classification endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
OHLC price series.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
adx_length : PositiveInt, optional
|
||||
ADX lookback window, by default 14.
|
||||
choppiness_length : PositiveInt, optional
|
||||
Choppiness Index lookback window, by default 14.
|
||||
adx_trend_threshold : PositiveFloat, optional
|
||||
ADX above this threshold marks a trending bar. Half this value is
|
||||
the cutoff for a weak trend, by default 25.0.
|
||||
choppiness_range_threshold : PositiveFloat, optional
|
||||
Choppiness above this threshold marks a ranging bar. The default
|
||||
61.8 is a common Fibonacci-derived cutoff.
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = ("date", "adx", "choppiness", "regime", "regime_changed")
|
||||
|
||||
data: list[Data] = Field(description="OHLC price series.")
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
adx_length: PositiveInt = Field(default=14, description="ADX lookback window.")
|
||||
choppiness_length: PositiveInt = Field(
|
||||
default=14,
|
||||
description="Choppiness Index lookback window.",
|
||||
)
|
||||
adx_trend_threshold: PositiveFloat = Field(
|
||||
default=25.0,
|
||||
description=(
|
||||
"ADX above this threshold marks a trending bar. Half this value is "
|
||||
"the cutoff for a weak trend."
|
||||
),
|
||||
)
|
||||
choppiness_range_threshold: PositiveFloat = Field(
|
||||
default=61.8,
|
||||
description=(
|
||||
"Choppiness above this threshold marks a ranging bar. The default "
|
||||
"61.8 is a common Fibonacci-derived cutoff."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RegimeData(Data):
|
||||
"""One bar of regime labelling alongside its underlying readings.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
adx : float, optional
|
||||
ADX reading at this bar. ``None`` during warm-up.
|
||||
choppiness : float, optional
|
||||
Choppiness Index reading at this bar. ``None`` during warm-up.
|
||||
regime : {"strong_trend", "weak_trend", "ranging", "transition"}
|
||||
Classified regime for this bar.
|
||||
regime_changed : bool
|
||||
``True`` when this bar's regime differs from the previous bar's.
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
adx: float | None = Field(
|
||||
description="ADX reading at this bar. ``None`` during warm-up."
|
||||
)
|
||||
choppiness: float | None = Field(
|
||||
description="Choppiness Index reading at this bar. ``None`` during warm-up.",
|
||||
)
|
||||
regime: Regime = Field(description="Classified regime for this bar.")
|
||||
regime_changed: bool = Field(
|
||||
description="``True`` when this bar's regime differs from the previous bar's.",
|
||||
)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
PythonEx(
|
||||
description="Daily regime labelling on SPY.",
|
||||
code=[
|
||||
"data = obb.equity.price.historical(symbol='SPY', start_date='2022-01-01', provider='yfinance').results",
|
||||
"regime = obb.technical.regime(data=data)",
|
||||
],
|
||||
),
|
||||
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
|
||||
],
|
||||
)
|
||||
def regime(params: RegimeQueryParams) -> OBBject[list[RegimeData]]:
|
||||
"""Label each bar with a market regime from ADX and Choppiness Index pairs."""
|
||||
import pandas as pd
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
validate_data(params.data, [params.adx_length, params.choppiness_length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
adx_df = df.ta.adx(length=params.adx_length)
|
||||
adx_series = adx_df[f"ADX_{params.adx_length}"]
|
||||
chop_series = df.ta.chop(length=params.choppiness_length).rename("choppiness")
|
||||
|
||||
combined = pd.DataFrame(
|
||||
{
|
||||
"adx": adx_series,
|
||||
"choppiness": chop_series,
|
||||
}
|
||||
)
|
||||
out = combined.reset_index().rename(columns={params.index: "date"})
|
||||
|
||||
results: list[RegimeData] = []
|
||||
prev_regime: Regime | None = None
|
||||
for row in out.to_dict(orient="records"):
|
||||
adx_val = row["adx"]
|
||||
chop_val = row["choppiness"]
|
||||
adx_clean = None if (adx_val is None or pd.isna(adx_val)) else float(adx_val)
|
||||
chop_clean = (
|
||||
None if (chop_val is None or pd.isna(chop_val)) else float(chop_val)
|
||||
)
|
||||
label = _classify(
|
||||
adx_clean,
|
||||
chop_clean,
|
||||
params.adx_trend_threshold,
|
||||
params.choppiness_range_threshold,
|
||||
)
|
||||
changed = prev_regime is not None and label != prev_regime
|
||||
results.append(
|
||||
RegimeData(
|
||||
date=row["date"],
|
||||
adx=adx_clean,
|
||||
choppiness=chop_clean,
|
||||
regime=label,
|
||||
regime_changed=changed,
|
||||
)
|
||||
)
|
||||
prev_regime = label
|
||||
return OBBject(results=results)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Regime",
|
||||
"RegimeData",
|
||||
"RegimeQueryParams",
|
||||
"regime",
|
||||
"router",
|
||||
]
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Oscillator threshold signals."""
|
||||
|
||||
from datetime import (
|
||||
date as dateType,
|
||||
datetime,
|
||||
)
|
||||
from typing import Literal
|
||||
|
||||
from openbb_core.app.model.example import APIEx
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
from openbb_core.app.router import Router
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
from openbb_technical.helpers import validate_data
|
||||
|
||||
# Bare 'date' alias for function signatures so the static-package builder
|
||||
# writes 'date' (which it imports from datetime) rather than 'dateType' which it does not.
|
||||
date = dateType
|
||||
|
||||
|
||||
router = Router(prefix="", description="Oscillator threshold signals.")
|
||||
|
||||
|
||||
OscillatorIndicator = Literal["rsi", "mfi", "stoch", "williams_r", "cci"]
|
||||
|
||||
|
||||
_DEFAULT_THRESHOLDS: dict[str, tuple[float, float]] = {
|
||||
"rsi": (70.0, 30.0),
|
||||
"mfi": (80.0, 20.0),
|
||||
"stoch": (80.0, 20.0),
|
||||
"williams_r": (-20.0, -80.0),
|
||||
"cci": (100.0, -100.0),
|
||||
}
|
||||
|
||||
|
||||
class OscillatorSignalsQueryParams(QueryParams):
|
||||
"""Query parameters for the oscillator threshold-signals endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : list[Data]
|
||||
Input OHLC(V) price series.
|
||||
target : str, optional
|
||||
Price column used for oscillators that take a single series
|
||||
(``rsi``, ``cci``), by default ``"close"``.
|
||||
index : str, optional
|
||||
Index column name in ``data``, by default ``"date"``.
|
||||
indicator : {"rsi", "mfi", "stoch", "williams_r", "cci"}
|
||||
Which oscillator to evaluate against its threshold bands.
|
||||
length : PositiveInt, optional
|
||||
Lookback window for the oscillator, by default 14.
|
||||
overbought_threshold : float, optional
|
||||
Upper band. ``None`` resolves to the indicator default
|
||||
(rsi 70, mfi 80, stoch 80, williams_r -20, cci 100).
|
||||
oversold_threshold : float, optional
|
||||
Lower band. ``None`` resolves to the indicator default
|
||||
(rsi 30, mfi 20, stoch 20, williams_r -80, cci -100).
|
||||
"""
|
||||
|
||||
__category__ = "signal"
|
||||
__output_columns__ = (
|
||||
"date",
|
||||
"value",
|
||||
"regime",
|
||||
"crossed_into_overbought",
|
||||
"crossed_into_oversold",
|
||||
"crossed_out_of_overbought",
|
||||
"crossed_out_of_oversold",
|
||||
)
|
||||
|
||||
data: list[Data] = Field(description="Input OHLC(V) price series.")
|
||||
target: str = Field(
|
||||
default="close",
|
||||
description="Price column used for oscillators that take a single series (rsi, cci).",
|
||||
)
|
||||
index: str = Field(default="date", description="Index column name in ``data``.")
|
||||
indicator: OscillatorIndicator = Field(
|
||||
description="Which oscillator to evaluate against its threshold bands.",
|
||||
)
|
||||
length: PositiveInt = Field(
|
||||
default=14, description="Lookback window for the oscillator."
|
||||
)
|
||||
overbought_threshold: float | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Upper band. ``None`` resolves to the indicator default "
|
||||
"(rsi 70, mfi 80, stoch 80, williams_r -20, cci 100)."
|
||||
),
|
||||
)
|
||||
oversold_threshold: float | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Lower band. ``None`` resolves to the indicator default "
|
||||
"(rsi 30, mfi 20, stoch 20, williams_r -80, cci -100)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class OscillatorSignal(Data):
|
||||
"""One row of the oscillator-signal series.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
date : date | str
|
||||
Observation date.
|
||||
value : float
|
||||
Oscillator value on this bar.
|
||||
regime : {"overbought", "oversold", "neutral"}
|
||||
Region of the oscillator on this bar relative to the configured bands.
|
||||
crossed_into_overbought : bool
|
||||
``True`` on the bar the regime first becomes overbought.
|
||||
crossed_into_oversold : bool
|
||||
``True`` on the bar the regime first becomes oversold.
|
||||
crossed_out_of_overbought : bool
|
||||
``True`` on the bar the regime leaves overbought (to neutral or
|
||||
oversold).
|
||||
crossed_out_of_oversold : bool
|
||||
``True`` on the bar the regime leaves oversold (to neutral or
|
||||
overbought).
|
||||
"""
|
||||
|
||||
date: datetime | dateType | str = Field(description="Observation date.")
|
||||
value: float = Field(description="Oscillator value on this bar.")
|
||||
regime: Literal["overbought", "oversold", "neutral"] = Field(
|
||||
description="Region of the oscillator on this bar.",
|
||||
)
|
||||
crossed_into_overbought: bool = Field(
|
||||
description="True on the bar the regime first becomes overbought.",
|
||||
)
|
||||
crossed_into_oversold: bool = Field(
|
||||
description="True on the bar the regime first becomes oversold.",
|
||||
)
|
||||
crossed_out_of_overbought: bool = Field(
|
||||
description="True on the bar the regime leaves overbought (to neutral or oversold).",
|
||||
)
|
||||
crossed_out_of_oversold: bool = Field(
|
||||
description="True on the bar the regime leaves oversold (to neutral or overbought).",
|
||||
)
|
||||
|
||||
|
||||
def _compute_oscillator(df, indicator: str, length: int, target: str):
|
||||
"""Dispatch to pandas_ta and return a single-series oscillator."""
|
||||
import pandas_ta as ta # noqa: F401
|
||||
|
||||
if indicator == "rsi":
|
||||
return df.ta.rsi(close=df[target], length=length)
|
||||
if indicator == "mfi":
|
||||
return df.ta.mfi(length=length)
|
||||
if indicator == "stoch":
|
||||
out = df.ta.stoch(k=length)
|
||||
return out.iloc[:, 0]
|
||||
if indicator == "williams_r":
|
||||
return df.ta.willr(length=length)
|
||||
return df.ta.cci(length=length)
|
||||
|
||||
|
||||
@router.command(
|
||||
methods=["POST"],
|
||||
examples=[
|
||||
APIEx(
|
||||
parameters={
|
||||
"data": APIEx.mock_data("timeseries"),
|
||||
"indicator": "rsi",
|
||||
"length": 14,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def oscillator_signals(
|
||||
params: OscillatorSignalsQueryParams,
|
||||
) -> OBBject[list[OscillatorSignal]]:
|
||||
"""Label every bar with its oscillator regime and emit crossing events."""
|
||||
import pandas as pd
|
||||
|
||||
validate_data(params.data, [params.length])
|
||||
df = basemodel_to_df(params.data, index=params.index)
|
||||
|
||||
ob_default, os_default = _DEFAULT_THRESHOLDS[params.indicator]
|
||||
ob = (
|
||||
params.overbought_threshold
|
||||
if params.overbought_threshold is not None
|
||||
else ob_default
|
||||
)
|
||||
osd = (
|
||||
params.oversold_threshold
|
||||
if params.oversold_threshold is not None
|
||||
else os_default
|
||||
)
|
||||
|
||||
raw = _compute_oscillator(df, params.indicator, params.length, params.target)
|
||||
series = pd.Series(raw, index=df.index, dtype="float64")
|
||||
|
||||
regime = pd.Series("neutral", index=series.index, dtype="object")
|
||||
regime[series >= ob] = "overbought"
|
||||
regime[series <= osd] = "oversold"
|
||||
|
||||
prev = regime.shift(1)
|
||||
crossed_into_overbought = (regime == "overbought") & (prev != "overbought")
|
||||
crossed_into_oversold = (regime == "oversold") & (prev != "oversold")
|
||||
crossed_out_of_overbought = (regime != "overbought") & (prev == "overbought")
|
||||
crossed_out_of_oversold = (regime != "oversold") & (prev == "oversold")
|
||||
|
||||
for s in (
|
||||
crossed_into_overbought,
|
||||
crossed_into_oversold,
|
||||
crossed_out_of_overbought,
|
||||
crossed_out_of_oversold,
|
||||
):
|
||||
s.iloc[0] = False
|
||||
|
||||
value = series.fillna(0.0)
|
||||
|
||||
out = (
|
||||
pd.DataFrame(
|
||||
{
|
||||
"value": value.astype(float),
|
||||
"regime": regime,
|
||||
"crossed_into_overbought": crossed_into_overbought.astype(bool),
|
||||
"crossed_into_oversold": crossed_into_oversold.astype(bool),
|
||||
"crossed_out_of_overbought": crossed_out_of_overbought.astype(bool),
|
||||
"crossed_out_of_oversold": crossed_out_of_oversold.astype(bool),
|
||||
}
|
||||
)
|
||||
.reset_index()
|
||||
.rename(columns={params.index: "date"})
|
||||
)
|
||||
|
||||
return OBBject(
|
||||
results=[OscillatorSignal(**row) for row in out.to_dict(orient="records")]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OscillatorIndicator",
|
||||
"OscillatorSignal",
|
||||
"OscillatorSignalsQueryParams",
|
||||
"oscillator_signals",
|
||||
"router",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
||||
"""Views for the technical Extension."""
|
||||
|
||||
# pylint: disable=too-many-locals,use-dict-literal
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openbb_charting.core.to_chart import to_chart
|
||||
from openbb_charting.styles.colors import LARGE_CYCLER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openbb_charting.core.openbb_figure import OpenBBFigure
|
||||
from openbb_charting.core.openbb_figure import (
|
||||
OpenBBFigure,
|
||||
)
|
||||
|
||||
|
||||
class TechnicalViews:
|
||||
@@ -52,8 +49,9 @@ class TechnicalViews:
|
||||
@staticmethod
|
||||
def technical_aroon(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Technical Aroon Chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
|
||||
from openbb_charting.core.plotly_ta.ta_class import (
|
||||
PlotlyTA,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -82,7 +80,7 @@ class TechnicalViews:
|
||||
symbol = kwargs.get("symbol", "")
|
||||
|
||||
ta = PlotlyTA()
|
||||
fig = ta.plot( # type: ignore
|
||||
fig = ta.plot(
|
||||
data,
|
||||
dict(aroon=dict(length=length, scalar=scalar)),
|
||||
title,
|
||||
@@ -97,8 +95,9 @@ class TechnicalViews:
|
||||
@staticmethod
|
||||
def technical_macd(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Plot moving average convergence divergence chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
|
||||
from openbb_charting.core.plotly_ta.ta_class import (
|
||||
PlotlyTA,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -126,7 +125,7 @@ class TechnicalViews:
|
||||
volume = kwargs.get("volume") is True
|
||||
|
||||
ta = PlotlyTA()
|
||||
fig = ta.plot( # type: ignore
|
||||
fig = ta.plot(
|
||||
data,
|
||||
dict(macd=dict(fast=fast, slow=slow, signal=signal)),
|
||||
title,
|
||||
@@ -140,8 +139,9 @@ class TechnicalViews:
|
||||
@staticmethod
|
||||
def technical_adx(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Average directional movement index chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
|
||||
from openbb_charting.core.plotly_ta.ta_class import (
|
||||
PlotlyTA,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -166,7 +166,7 @@ class TechnicalViews:
|
||||
symbol = kwargs.get("symbol", "")
|
||||
|
||||
ta = PlotlyTA()
|
||||
fig = ta.plot( # type: ignore
|
||||
fig = ta.plot(
|
||||
data,
|
||||
dict(adx=dict(length=length, scalar=scalar, drift=drift)),
|
||||
f"Average Directional Movement Index (ADX) {symbol}",
|
||||
@@ -180,8 +180,9 @@ class TechnicalViews:
|
||||
@staticmethod
|
||||
def technical_rsi(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Relative strength index chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
|
||||
from openbb_charting.core.plotly_ta.ta_class import (
|
||||
PlotlyTA,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -206,7 +207,7 @@ class TechnicalViews:
|
||||
symbol = kwargs.get("symbol", "")
|
||||
|
||||
ta = PlotlyTA()
|
||||
fig = ta.plot( # type: ignore
|
||||
fig = ta.plot(
|
||||
data,
|
||||
dict(rsi=dict(length=window, scalar=scalar, drift=drift)),
|
||||
f"{symbol.upper()} RSI {window}",
|
||||
@@ -220,9 +221,12 @@ class TechnicalViews:
|
||||
@staticmethod
|
||||
def technical_cones(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Volatility Cones Chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.chart_style import ChartStyle
|
||||
from openbb_charting.core.openbb_figure import OpenBBFigure
|
||||
from openbb_charting.core.chart_style import (
|
||||
ChartStyle,
|
||||
)
|
||||
from openbb_charting.core.openbb_figure import (
|
||||
OpenBBFigure,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -231,11 +235,10 @@ class TechnicalViews:
|
||||
if isinstance(data, DataFrame) and not data.empty and "window" in data.columns:
|
||||
df_ta = data.set_index("window")
|
||||
else:
|
||||
df_ta = basemodel_to_df(kwargs["obbject_item"], index="window") # type: ignore
|
||||
df_ta = basemodel_to_df(kwargs["obbject_item"], index="window")
|
||||
|
||||
df_ta.columns = [col.title().replace("_", " ") for col in df_ta.columns]
|
||||
|
||||
# Check if the data is formatted as expected.
|
||||
if not all(
|
||||
col in df_ta.columns for col in ["Realized", "Min", "Median", "Max"]
|
||||
):
|
||||
@@ -333,22 +336,23 @@ class TechnicalViews:
|
||||
**kwargs: Any,
|
||||
) -> tuple["OpenBBFigure", dict[str, Any]]:
|
||||
"""Relative Rotation Chart."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.charts import relative_rotation # noqa
|
||||
from openbb_charting.core.chart_style import ChartStyle # noqa
|
||||
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
|
||||
from openbb_core.app.utils import basemodel_to_df # noqa
|
||||
|
||||
ratios_df = basemodel_to_df(kwargs["obbject_item"].rs_ratios, index="date") # type: ignore
|
||||
momentum_df = basemodel_to_df(kwargs["obbject_item"].rs_momentum, index="date") # type: ignore
|
||||
benchmark_symbol = kwargs["obbject_item"].benchmark # type: ignore
|
||||
ratios_df = basemodel_to_df(kwargs["obbject_item"].rs_ratios, index="date")
|
||||
momentum_df = basemodel_to_df(kwargs["obbject_item"].rs_momentum, index="date")
|
||||
benchmark_symbol = kwargs["obbject_item"].benchmark
|
||||
study = kwargs.get("study")
|
||||
study = str(kwargs["obbject_item"].study) if study is None else str(study)
|
||||
show_tails = kwargs.get("show_tails")
|
||||
show_tails = True if show_tails is None else show_tails
|
||||
tail_periods = int(kwargs.get("tail_periods")) if "tail_periods" in kwargs else 16 # type: ignore
|
||||
tail_interval = str(kwargs.get("tail_interval")) if "tail_interval" in kwargs else "week" # type: ignore
|
||||
date = kwargs.get("date") if "date" in kwargs else None # type: ignore
|
||||
tail_periods = int(kwargs["tail_periods"]) if "tail_periods" in kwargs else 16
|
||||
tail_interval = (
|
||||
str(kwargs.get("tail_interval")) if "tail_interval" in kwargs else "week"
|
||||
)
|
||||
date = kwargs.get("date") if "date" in kwargs else None
|
||||
show_tails = False if date is not None else show_tails
|
||||
if ratios_df.empty or momentum_df.empty:
|
||||
raise RuntimeError("Error: No data to plot.")
|
||||
@@ -360,7 +364,7 @@ class TechnicalViews:
|
||||
study,
|
||||
benchmark_symbol,
|
||||
tail_periods,
|
||||
tail_interval, # type: ignore
|
||||
tail_interval,
|
||||
)
|
||||
|
||||
if show_tails is False:
|
||||
@@ -369,10 +373,10 @@ class TechnicalViews:
|
||||
momentum_df,
|
||||
benchmark_symbol,
|
||||
study,
|
||||
date, # type: ignore
|
||||
date,
|
||||
)
|
||||
|
||||
figure = OpenBBFigure(fig) # pylint: disable=E0606
|
||||
figure = OpenBBFigure(fig)
|
||||
font_color = "black" if ChartStyle().plt_style == "light" else "white"
|
||||
figure.update_layout(
|
||||
plot_bgcolor="rgba(255,255,255,1)",
|
||||
@@ -413,9 +417,16 @@ class TechnicalViews:
|
||||
|
||||
def _ta_ma(**kwargs):
|
||||
"""Plot moving average helper."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from openbb_charting.core.chart_style import ChartStyle
|
||||
from openbb_charting.core.openbb_figure import OpenBBFigure
|
||||
from openbb_charting.core.chart_style import (
|
||||
ChartStyle,
|
||||
)
|
||||
from openbb_charting.core.openbb_figure import (
|
||||
OpenBBFigure,
|
||||
)
|
||||
from openbb_charting.core.to_chart import to_chart
|
||||
from openbb_charting.styles.colors import (
|
||||
LARGE_CYCLER,
|
||||
)
|
||||
from openbb_core.app.utils import basemodel_to_df
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -439,7 +450,10 @@ def _ta_ma(**kwargs):
|
||||
data = basemodel_to_df(kwargs["obbject_item"], index=index)
|
||||
|
||||
if isinstance(data, list):
|
||||
data = basemodel_to_df(data, index=index)
|
||||
# ``data`` is opaque ``Any`` until the isinstance narrows it, and
|
||||
# ``basemodel_to_df`` declares ``list[Data] | Data``. Callers pass
|
||||
# a list of ``Data``-compatible models here at runtime.
|
||||
data = basemodel_to_df(data, index=index) # ty: ignore[invalid-argument-type]
|
||||
|
||||
window = (
|
||||
kwargs.get("length", [])
|
||||
|
||||
2385
openbb_platform/extensions/technical/poetry.lock
generated
vendored
2385
openbb_platform/extensions/technical/poetry.lock
generated
vendored
File diff suppressed because it is too large
Load Diff
109
openbb_platform/extensions/technical/pyproject.toml
vendored
109
openbb_platform/extensions/technical/pyproject.toml
vendored
@@ -1,24 +1,101 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "openbb-technical"
|
||||
version = "1.6.1"
|
||||
version = "2.0.0"
|
||||
description = "Technical Analysis extension for OpenBB"
|
||||
authors = ["OpenBB Team <hello@openbb.co>"]
|
||||
license = "AGPL-3.0-only"
|
||||
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
|
||||
license = { text = "AGPL-3.0-only" }
|
||||
readme = "README.md"
|
||||
packages = [{ include = "openbb_technical" }]
|
||||
requires-python = ">=3.10,<4"
|
||||
dependencies = [
|
||||
"openbb-core[pandas]>=2.0.0",
|
||||
"pandas-ta-openbb>=0.4.23",
|
||||
"scikit-learn>=1.6.0",
|
||||
"scipy>=1.11",
|
||||
"statsmodels>=0.14",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<4"
|
||||
openbb-core = "^1.6.3"
|
||||
pandas-ta-openbb = "^0.4.23"
|
||||
scikit-learn = "^1.6.0"
|
||||
[project.entry-points."openbb_core_extension"]
|
||||
technical = "openbb_technical.router:router"
|
||||
|
||||
[project.entry-points."openbb_charting_extension"]
|
||||
technical = "openbb_technical.technical_views:TechnicalViews"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://openbb.co"
|
||||
Repository = "https://github.com/OpenBB-finance/OpenBB"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["openbb-devtools>=2.0.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.poetry.plugins."openbb_core_extension"]
|
||||
technical = "openbb_technical.technical_router:router"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["openbb_technical"]
|
||||
|
||||
[tool.poetry.plugins."openbb_charting_extension"]
|
||||
technical = "openbb_technical.technical_views:TechnicalViews"
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"openbb_technical",
|
||||
"README.md",
|
||||
"pyproject.toml",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
openbb-core = { path = "../../core", editable = true }
|
||||
openbb-devtools = { path = "../devtools", editable = true }
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
fix = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
|
||||
ignore = [
|
||||
"PLR2004", "PLR0913", "PLR0915", "PLC0415",
|
||||
"E402", "E501",
|
||||
"D101", "D102", "D103", "D105", "D107",
|
||||
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
|
||||
"D215", "D300", "D400", "D401", "D404", "D405", "D406", "D407",
|
||||
"D408", "D409", "D410", "D411", "D413", "D415", "D416", "D417",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"openbb_technical/helpers.py" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917"]
|
||||
"openbb_technical/relative_rotation.py" = ["PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "SLF001", "ARG002"]
|
||||
"openbb_technical/technical_views.py" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "SLF001", "ARG002"]
|
||||
"openbb_technical/indicators/**" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "SLF001", "ARG001", "ARG002"]
|
||||
"openbb_technical/signals/**" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "SLF001", "ARG001", "ARG002", "E741"]
|
||||
"openbb_technical/multi/**" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "SLF001", "ARG001", "ARG002"]
|
||||
"tests/**" = ["S101", "S104", "S105", "S106", "S108", "D", "F841", "PLR0911", "PLW0108", "SIM105", "SIM117", "E741"]
|
||||
"integration/**" = ["S101", "S104", "S105", "S106", "S108", "D", "PLC0415", "PLR0911", "PLR0913", "PLR0914", "PLR0915", "PLR0917"]
|
||||
"**/__init__.py" = ["F401"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
combine-as-imports = true
|
||||
force-wrap-aliases = true
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "numpy"
|
||||
|
||||
[tool.ty]
|
||||
[tool.ty.environment]
|
||||
python-version = "3.10"
|
||||
|
||||
[tool.ty.src]
|
||||
include = ["openbb_technical"]
|
||||
exclude = ["tests", "integration"]
|
||||
|
||||
[tool.ty.rules]
|
||||
unresolved-import = "warn"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-q --strict-markers"
|
||||
testpaths = ["tests"]
|
||||
markers = ["integration: real-data tests; not part of the default ``pytest tests`` run."]
|
||||
filterwarnings = [
|
||||
"ignore:Inheritance class ClientSession from ClientSession is discouraged:DeprecationWarning",
|
||||
"ignore:Using `@model_validator` with mode='after' on a classmethod is deprecated:DeprecationWarning",
|
||||
]
|
||||
|
||||
37
openbb_platform/extensions/technical/tests/conftest.py
Normal file
37
openbb_platform/extensions/technical/tests/conftest.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Shared fixtures for the openbb-technical test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ohlcv_df() -> pd.DataFrame:
|
||||
"""Return a deterministic OHLCV DataFrame indexed by daily dates.
|
||||
|
||||
Used by helpers, router, and views tests. The values are a smooth
|
||||
monotone series so volatility / momentum / trend computations all
|
||||
produce non-NaN, finite results without per-test seeding.
|
||||
"""
|
||||
periods = 99
|
||||
base = np.arange(1, periods + 1, dtype="float64")
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": base,
|
||||
"high": base + 0.5,
|
||||
"low": base - 0.5,
|
||||
"close": base,
|
||||
"volume": base * 100,
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=periods, freq="D", name="date"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ohlcv_records(ohlcv_df: pd.DataFrame) -> list[dict]:
|
||||
"""List-of-dicts form of ``ohlcv_df`` for ``Data``-coercion router tests."""
|
||||
out = ohlcv_df.reset_index()
|
||||
out["date"] = out["date"].dt.strftime("%Y-%m-%d")
|
||||
return out.to_dict(orient="records")
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Tests for openbb_technical.indicators.oscillators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.oscillators import (
|
||||
AwesomeOscillatorData,
|
||||
AwesomeOscillatorQueryParams,
|
||||
CciData,
|
||||
CciQueryParams,
|
||||
CgData,
|
||||
CgQueryParams,
|
||||
FisherData,
|
||||
FisherQueryParams,
|
||||
MfiData,
|
||||
MfiQueryParams,
|
||||
RsiData,
|
||||
RsiQueryParams,
|
||||
StochData,
|
||||
StochQueryParams,
|
||||
TrixData,
|
||||
TrixQueryParams,
|
||||
UltimateOscillatorData,
|
||||
UltimateOscillatorQueryParams,
|
||||
WilliamsRData,
|
||||
WilliamsRQueryParams,
|
||||
awesome_oscillator,
|
||||
cci,
|
||||
cg,
|
||||
fisher,
|
||||
mfi,
|
||||
rsi,
|
||||
stoch,
|
||||
trix,
|
||||
ultimate_oscillator,
|
||||
williams_r,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
class TestRsi:
|
||||
def test_default(self, long_records):
|
||||
result = rsi(RsiQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, RsiData) for r in result.results)
|
||||
assert result.results[0].rsi is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = rsi(RsiQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
def test_custom_target(self, long_records):
|
||||
result = rsi(RsiQueryParams(data=long_records, target="high"))
|
||||
assert result.results
|
||||
|
||||
def test_custom_scalar(self, long_records):
|
||||
result = rsi(RsiQueryParams(data=long_records, scalar=50.0))
|
||||
assert result.results
|
||||
|
||||
def test_custom_drift(self, long_records):
|
||||
result = rsi(RsiQueryParams(data=long_records, drift=2))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestStoch:
|
||||
def test_default(self, long_records):
|
||||
result = stoch(StochQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, StochData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.k is not None
|
||||
assert first.d is not None
|
||||
|
||||
def test_custom_fast_k(self, long_records):
|
||||
result = stoch(StochQueryParams(data=long_records, fast_k_period=10))
|
||||
assert result.results
|
||||
|
||||
def test_custom_slow_d(self, long_records):
|
||||
result = stoch(StochQueryParams(data=long_records, slow_d_period=5))
|
||||
assert result.results
|
||||
|
||||
def test_custom_slow_k(self, long_records):
|
||||
result = stoch(StochQueryParams(data=long_records, slow_k_period=4))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestCci:
|
||||
def test_default(self, long_records):
|
||||
result = cci(CciQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, CciData) for r in result.results)
|
||||
assert result.results[0].cci is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = cci(CciQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
def test_custom_scalar(self, long_records):
|
||||
result = cci(CciQueryParams(data=long_records, scalar=0.02))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestFisher:
|
||||
def test_default(self, long_records):
|
||||
result = fisher(FisherQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, FisherData) for r in result.results)
|
||||
assert result.results[0].fisher is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = fisher(FisherQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
def test_custom_signal(self, long_records):
|
||||
result = fisher(FisherQueryParams(data=long_records, signal=3))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestCg:
|
||||
def test_default(self, long_records):
|
||||
result = cg(CgQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, CgData) for r in result.results)
|
||||
assert result.results[0].cg is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = cg(CgQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestWilliamsR:
|
||||
def test_default(self, long_records):
|
||||
result = williams_r(WilliamsRQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, WilliamsRData) for r in result.results)
|
||||
assert result.results[0].williams_r is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = williams_r(WilliamsRQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestMfi:
|
||||
def test_default(self, long_records):
|
||||
result = mfi(MfiQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, MfiData) for r in result.results)
|
||||
assert result.results[0].mfi is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = mfi(MfiQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestTrix:
|
||||
def test_default(self, long_records):
|
||||
result = trix(TrixQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, TrixData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.trix is not None
|
||||
assert first.signal is not None
|
||||
|
||||
def test_custom_length(self, long_records):
|
||||
result = trix(TrixQueryParams(data=long_records, length=15))
|
||||
assert result.results
|
||||
|
||||
def test_custom_signal(self, long_records):
|
||||
result = trix(TrixQueryParams(data=long_records, signal=5))
|
||||
assert result.results
|
||||
|
||||
def test_custom_target(self, long_records):
|
||||
result = trix(TrixQueryParams(data=long_records, target="high"))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestUltimateOscillator:
|
||||
def test_default(self, long_records):
|
||||
result = ultimate_oscillator(UltimateOscillatorQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, UltimateOscillatorData) for r in result.results)
|
||||
assert result.results[0].ultimate_oscillator is not None
|
||||
|
||||
def test_custom_fast(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, fast=5)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_medium(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, medium=10)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_slow(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, slow=20)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_fast_weight(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, fast_weight=5.0)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_medium_weight(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, medium_weight=3.0)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_slow_weight(self, long_records):
|
||||
result = ultimate_oscillator(
|
||||
UltimateOscillatorQueryParams(data=long_records, slow_weight=2.0)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestAwesomeOscillator:
|
||||
def test_default(self, long_records):
|
||||
result = awesome_oscillator(AwesomeOscillatorQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AwesomeOscillatorData) for r in result.results)
|
||||
assert result.results[0].awesome_oscillator is not None
|
||||
|
||||
def test_custom_fast(self, long_records):
|
||||
result = awesome_oscillator(
|
||||
AwesomeOscillatorQueryParams(data=long_records, fast=3)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_custom_slow(self, long_records):
|
||||
result = awesome_oscillator(
|
||||
AwesomeOscillatorQueryParams(data=long_records, slow=20)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
"""Field-level coverage of the QueryParams classes."""
|
||||
|
||||
def test_rsi_defaults(self, long_records):
|
||||
params = RsiQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.scalar == 100.0
|
||||
assert params.drift == 1
|
||||
assert params.target == "close"
|
||||
|
||||
def test_stoch_defaults(self, long_records):
|
||||
params = StochQueryParams(data=long_records)
|
||||
assert params.fast_k_period == 14
|
||||
assert params.slow_d_period == 3
|
||||
assert params.slow_k_period == 3
|
||||
|
||||
def test_cci_defaults(self, long_records):
|
||||
params = CciQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.scalar == 0.015
|
||||
|
||||
def test_fisher_defaults(self, long_records):
|
||||
params = FisherQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.signal == 1
|
||||
|
||||
def test_cg_defaults(self, long_records):
|
||||
params = CgQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
|
||||
def test_williams_r_defaults(self, long_records):
|
||||
params = WilliamsRQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
|
||||
def test_mfi_defaults(self, long_records):
|
||||
params = MfiQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
|
||||
def test_trix_defaults(self, long_records):
|
||||
params = TrixQueryParams(data=long_records)
|
||||
assert params.length == 30
|
||||
assert params.signal == 9
|
||||
assert params.target == "close"
|
||||
|
||||
def test_ultimate_oscillator_defaults(self, long_records):
|
||||
params = UltimateOscillatorQueryParams(data=long_records)
|
||||
assert params.fast == 7
|
||||
assert params.medium == 14
|
||||
assert params.slow == 28
|
||||
assert params.fast_weight == 4.0
|
||||
assert params.medium_weight == 2.0
|
||||
assert params.slow_weight == 1.0
|
||||
|
||||
def test_awesome_oscillator_defaults(self, long_records):
|
||||
params = AwesomeOscillatorQueryParams(data=long_records)
|
||||
assert params.fast == 5
|
||||
assert params.slow == 34
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Tests for openbb_technical.indicators.overlays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.overlays import (
|
||||
BbandsData,
|
||||
BbandsQueryParams,
|
||||
DemaData,
|
||||
DemaQueryParams,
|
||||
DonchianData,
|
||||
DonchianQueryParams,
|
||||
EmaData,
|
||||
EmaQueryParams,
|
||||
FramaData,
|
||||
FramaQueryParams,
|
||||
HmaData,
|
||||
HmaQueryParams,
|
||||
IchimokuData,
|
||||
IchimokuQueryParams,
|
||||
KamaData,
|
||||
KamaQueryParams,
|
||||
KcData,
|
||||
KcQueryParams,
|
||||
SmaData,
|
||||
SmaQueryParams,
|
||||
SupertrendData,
|
||||
SupertrendQueryParams,
|
||||
TemaData,
|
||||
TemaQueryParams,
|
||||
VwmaData,
|
||||
VwmaQueryParams,
|
||||
WmaData,
|
||||
WmaQueryParams,
|
||||
ZlmaData,
|
||||
ZlmaQueryParams,
|
||||
_nan_to_none,
|
||||
bbands,
|
||||
dema,
|
||||
donchian,
|
||||
ema,
|
||||
frama,
|
||||
hma,
|
||||
ichimoku,
|
||||
kama,
|
||||
kc,
|
||||
sma,
|
||||
supertrend,
|
||||
tema,
|
||||
vwma,
|
||||
wma,
|
||||
zlma,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
class TestSma:
|
||||
def test_default(self, long_records):
|
||||
result = sma(SmaQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, SmaData) for r in result.results)
|
||||
assert result.results[0].sma is not None
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = sma(SmaQueryParams(data=long_records, length=20, offset=1))
|
||||
assert result.results
|
||||
|
||||
def test_alt_target(self, long_records):
|
||||
result = sma(SmaQueryParams(data=long_records, length=20, target="open"))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestEma:
|
||||
def test_default(self, long_records):
|
||||
result = ema(EmaQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, EmaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = ema(EmaQueryParams(data=long_records, length=20, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestHma:
|
||||
def test_default(self, long_records):
|
||||
result = hma(HmaQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, HmaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = hma(HmaQueryParams(data=long_records, length=20, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestWma:
|
||||
def test_default(self, long_records):
|
||||
result = wma(WmaQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, WmaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = wma(WmaQueryParams(data=long_records, length=20, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestZlma:
|
||||
def test_default(self, long_records):
|
||||
result = zlma(ZlmaQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, ZlmaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = zlma(ZlmaQueryParams(data=long_records, length=20, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestTema:
|
||||
def test_default(self, long_records):
|
||||
result = tema(TemaQueryParams(data=long_records, length=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, TemaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = tema(TemaQueryParams(data=long_records, length=10, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestDema:
|
||||
def test_default(self, long_records):
|
||||
result = dema(DemaQueryParams(data=long_records, length=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, DemaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = dema(DemaQueryParams(data=long_records, length=10, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestKama:
|
||||
def test_default(self, long_records):
|
||||
result = kama(KamaQueryParams(data=long_records, length=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, KamaData) for r in result.results)
|
||||
|
||||
def test_fast_slow(self, long_records):
|
||||
result = kama(KamaQueryParams(data=long_records, length=10, fast=3, slow=15))
|
||||
assert result.results
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = kama(KamaQueryParams(data=long_records, length=10, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestFrama:
|
||||
def test_default(self, long_records):
|
||||
result = frama(FramaQueryParams(data=long_records, window=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, FramaData) for r in result.results)
|
||||
assert result.results[0].frama is not None
|
||||
|
||||
def test_odd_window_rejected(self, long_records):
|
||||
with pytest.raises(ValueError, match="frama requires an even window"):
|
||||
FramaQueryParams(data=long_records, window=9)
|
||||
|
||||
def test_larger_even_window(self, long_records):
|
||||
result = frama(FramaQueryParams(data=long_records, window=20))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestVwma:
|
||||
def test_default(self, long_records):
|
||||
result = vwma(VwmaQueryParams(data=long_records, length=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, VwmaData) for r in result.results)
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = vwma(VwmaQueryParams(data=long_records, length=10, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestBbands:
|
||||
def test_default(self, long_records):
|
||||
result = bbands(BbandsQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, BbandsData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.lower is not None
|
||||
assert first.middle is not None
|
||||
assert first.upper is not None
|
||||
assert first.bandwidth is not None
|
||||
assert first.percent is not None
|
||||
|
||||
@pytest.mark.parametrize("mamode", ["sma", "ema", "wma", "rma"])
|
||||
def test_each_mamode(self, long_records, mamode):
|
||||
result = bbands(BbandsQueryParams(data=long_records, length=20, mamode=mamode))
|
||||
assert result.results
|
||||
|
||||
def test_std_offset(self, long_records):
|
||||
result = bbands(
|
||||
BbandsQueryParams(data=long_records, length=20, std=2.5, offset=1)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_alt_target(self, long_records):
|
||||
result = bbands(BbandsQueryParams(data=long_records, length=20, target="open"))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestDonchian:
|
||||
def test_default(self, long_records):
|
||||
result = donchian(DonchianQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, DonchianData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.lower is not None
|
||||
assert first.middle is not None
|
||||
assert first.upper is not None
|
||||
|
||||
def test_asymmetric_lengths(self, long_records):
|
||||
result = donchian(
|
||||
DonchianQueryParams(
|
||||
data=long_records, lower_length=10, upper_length=30, offset=1
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestKc:
|
||||
def test_default(self, long_records):
|
||||
result = kc(KcQueryParams(data=long_records, length=20))
|
||||
assert result.results
|
||||
assert all(isinstance(r, KcData) for r in result.results)
|
||||
|
||||
@pytest.mark.parametrize("mamode", ["ema", "sma"])
|
||||
def test_each_mamode(self, long_records, mamode):
|
||||
result = kc(KcQueryParams(data=long_records, length=20, mamode=mamode))
|
||||
assert result.results
|
||||
|
||||
def test_scalar_offset(self, long_records):
|
||||
result = kc(KcQueryParams(data=long_records, length=20, scalar=2.5, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestIchimoku:
|
||||
def test_default(self, long_records):
|
||||
result = ichimoku(IchimokuQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, IchimokuData) for r in result.results)
|
||||
assert all(r.chikou_span is None for r in result.results)
|
||||
|
||||
def test_lookahead_includes_chikou(self, long_records):
|
||||
result = ichimoku(IchimokuQueryParams(data=long_records, lookahead=True))
|
||||
assert any(r.chikou_span is not None for r in result.results)
|
||||
|
||||
def test_alt_periods(self, long_records):
|
||||
result = ichimoku(
|
||||
IchimokuQueryParams(
|
||||
data=long_records, conversion=5, base=10, lagging=20, offset=5
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestSupertrend:
|
||||
def test_default(self, long_records):
|
||||
result = supertrend(SupertrendQueryParams(data=long_records, length=7))
|
||||
assert result.results
|
||||
assert all(isinstance(r, SupertrendData) for r in result.results)
|
||||
directions = {r.direction for r in result.results if r.direction is not None}
|
||||
assert directions.issubset({-1, 1})
|
||||
|
||||
def test_multiplier(self, long_records):
|
||||
result = supertrend(
|
||||
SupertrendQueryParams(data=long_records, length=7, multiplier=2.0)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_short_band_path(self, ohlcv_df):
|
||||
"""Use a monotone-falling close to force the short-band branch."""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
periods = 99
|
||||
base = np.arange(periods, 0, -1, dtype="float64")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": base,
|
||||
"high": base + 0.5,
|
||||
"low": base - 0.5,
|
||||
"close": base,
|
||||
"volume": base * 100,
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=periods, freq="D", name="date"),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = supertrend(
|
||||
SupertrendQueryParams(data=records, length=7, multiplier=3.0)
|
||||
)
|
||||
assert any(r.direction == -1 for r in result.results)
|
||||
assert any(r.short_band is not None for r in result.results)
|
||||
|
||||
|
||||
class TestNanHelper:
|
||||
def test_replaces_nan(self):
|
||||
out = _nan_to_none({"a": float("nan"), "b": 1.0, "c": "x"})
|
||||
assert out == {"a": None, "b": 1.0, "c": "x"}
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
"""Field-level coverage of QueryParams classes."""
|
||||
|
||||
def test_sma_defaults(self, long_records):
|
||||
params = SmaQueryParams(data=long_records)
|
||||
assert params.length == 50
|
||||
assert params.target == "close"
|
||||
|
||||
def test_ema_defaults(self, long_records):
|
||||
assert EmaQueryParams(data=long_records).length == 50
|
||||
|
||||
def test_hma_defaults(self, long_records):
|
||||
assert HmaQueryParams(data=long_records).length == 50
|
||||
|
||||
def test_wma_defaults(self, long_records):
|
||||
assert WmaQueryParams(data=long_records).length == 50
|
||||
|
||||
def test_zlma_defaults(self, long_records):
|
||||
assert ZlmaQueryParams(data=long_records).length == 50
|
||||
|
||||
def test_tema_defaults(self, long_records):
|
||||
assert TemaQueryParams(data=long_records).length == 10
|
||||
|
||||
def test_dema_defaults(self, long_records):
|
||||
assert DemaQueryParams(data=long_records).length == 10
|
||||
|
||||
def test_kama_defaults(self, long_records):
|
||||
params = KamaQueryParams(data=long_records)
|
||||
assert params.fast == 2
|
||||
assert params.slow == 30
|
||||
|
||||
def test_frama_defaults(self, long_records):
|
||||
assert FramaQueryParams(data=long_records).window == 10
|
||||
|
||||
def test_vwma_defaults(self, long_records):
|
||||
assert VwmaQueryParams(data=long_records).length == 10
|
||||
|
||||
def test_bbands_defaults(self, long_records):
|
||||
params = BbandsQueryParams(data=long_records)
|
||||
assert params.length == 20
|
||||
assert params.std == 2.0
|
||||
assert params.mamode == "sma"
|
||||
|
||||
def test_donchian_defaults(self, long_records):
|
||||
params = DonchianQueryParams(data=long_records)
|
||||
assert params.lower_length == 20
|
||||
assert params.upper_length == 20
|
||||
|
||||
def test_kc_defaults(self, long_records):
|
||||
params = KcQueryParams(data=long_records)
|
||||
assert params.length == 20
|
||||
assert params.scalar == 2.0
|
||||
assert params.mamode == "ema"
|
||||
|
||||
def test_ichimoku_defaults(self, long_records):
|
||||
params = IchimokuQueryParams(data=long_records)
|
||||
assert params.conversion == 9
|
||||
assert params.base == 26
|
||||
assert params.lagging == 52
|
||||
assert params.lookahead is False
|
||||
|
||||
def test_supertrend_defaults(self, long_records):
|
||||
params = SupertrendQueryParams(data=long_records)
|
||||
assert params.length == 7
|
||||
assert params.multiplier == 3.0
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for the relative-rotation router shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from openbb_technical.indicators.relative_rotation import relative_rotation
|
||||
from openbb_technical.relative_rotation import (
|
||||
RelativeRotationData,
|
||||
RelativeRotationQueryParams,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def multi_symbol_records() -> list[dict]:
|
||||
"""3-symbol × 600-day synthetic price history; matches the existing relative-rotation tests."""
|
||||
dates = pd.date_range("2020-01-01", periods=600, freq="D")
|
||||
records: list[dict] = []
|
||||
for offset, symbol in enumerate(["AAPL", "MSFT", "SPY"]):
|
||||
for i, dt in enumerate(dates):
|
||||
records.append(
|
||||
{
|
||||
"date": dt.strftime("%Y-%m-%d"),
|
||||
"symbol": symbol,
|
||||
"close": float(100 + offset * 10 + i * (1 + offset * 0.1)),
|
||||
"volume": float(
|
||||
1_000_000 + offset * 50_000 + i * (10 + offset * 3)
|
||||
),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
class TestRelativeRotationRouter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_relative_rotation_data(self, multi_symbol_records):
|
||||
result = await relative_rotation(
|
||||
RelativeRotationQueryParams(data=multi_symbol_records, benchmark="SPY")
|
||||
)
|
||||
assert isinstance(result.results, RelativeRotationData)
|
||||
assert result.results.benchmark == "SPY"
|
||||
assert "AAPL" in result.results.symbols
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Tests for openbb_technical.indicators.statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.statistics import (
|
||||
AutocorrelationData,
|
||||
AutocorrelationQueryParams,
|
||||
ClenowData,
|
||||
ClenowQueryParams,
|
||||
DrawdownData,
|
||||
DrawdownQueryParams,
|
||||
HurstData,
|
||||
HurstQueryParams,
|
||||
ReturnsStatsData,
|
||||
ReturnsStatsQueryParams,
|
||||
StationarityData,
|
||||
StationarityQueryParams,
|
||||
_hurst_dfa,
|
||||
_hurst_rs,
|
||||
_interpret_hurst,
|
||||
_overall_verdict,
|
||||
_stats_block,
|
||||
autocorrelation,
|
||||
clenow,
|
||||
drawdown,
|
||||
hurst,
|
||||
returns_stats,
|
||||
stationarity,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard 99-row OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stationary_records():
|
||||
"""500 rows of i.i.d. Gaussian noise — stationary by construction."""
|
||||
rng = np.random.default_rng(0)
|
||||
n = 500
|
||||
y = rng.normal(loc=100.0, scale=1.0, size=n)
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(n)},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def random_walk_records():
|
||||
"""500 rows of a Gaussian random walk — non-stationary by construction."""
|
||||
rng = np.random.default_rng(1)
|
||||
n = 500
|
||||
y = rng.normal(size=n).cumsum() + 100.0
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(n)},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def trend_stationary_records():
|
||||
"""Linear trend plus noise — trend-stationary under ``ct`` regression."""
|
||||
rng = np.random.default_rng(42)
|
||||
n = 300
|
||||
t = np.arange(n, dtype=float)
|
||||
y = 0.1 * t + rng.normal(size=n)
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(n)},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def realistic_returns_records():
|
||||
"""A year of log-normal returns suitable for Sharpe/Sortino/Calmar."""
|
||||
rng = np.random.default_rng(7)
|
||||
n = 252
|
||||
y = np.cumprod(1.0 + rng.normal(0.001, 0.02, n)) * 100.0
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(n)},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def monotone_up_records():
|
||||
"""Strictly monotone rising series — no downside returns, no drawdown."""
|
||||
y = np.array([100.0, 101.0, 102.5, 105.0, 108.0, 110.0, 115.0, 120.0, 130.0, 145.0])
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(len(y))},
|
||||
index=pd.date_range("2021-01-01", periods=len(y), freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def constant_records():
|
||||
"""Constant series — zero returns, zero variance."""
|
||||
y = np.full(50, 100.0)
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(50)},
|
||||
index=pd.date_range("2021-01-01", periods=50, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
class TestClenow:
|
||||
def test_default(self, long_records):
|
||||
result = clenow(ClenowQueryParams(data=long_records, period=20))
|
||||
assert len(result.results) == 20
|
||||
assert all(isinstance(r, ClenowData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.predicted is not None
|
||||
assert first.r2 == result.results[-1].r2
|
||||
assert first.coefficient == result.results[-1].coefficient
|
||||
assert first.annualized_coefficient == pytest.approx(
|
||||
first.coefficient * first.r2
|
||||
)
|
||||
|
||||
def test_alternate_target(self, long_records):
|
||||
result = clenow(ClenowQueryParams(data=long_records, target="open", period=20))
|
||||
assert result.results
|
||||
|
||||
def test_defaults_via_params(self, long_records):
|
||||
params = ClenowQueryParams(data=long_records)
|
||||
assert params.period == 90
|
||||
assert params.target == "close"
|
||||
|
||||
|
||||
class TestDrawdown:
|
||||
def test_default(self, long_records):
|
||||
result = drawdown(DrawdownQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, DrawdownData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.cumulative_return == pytest.approx(0.0)
|
||||
assert first.drawdown_duration_days == 0
|
||||
|
||||
def test_monotone_up_has_zero_drawdown(self, monotone_up_records):
|
||||
result = drawdown(DrawdownQueryParams(data=monotone_up_records))
|
||||
assert all(r.drawdown == pytest.approx(0.0) for r in result.results)
|
||||
assert all(r.drawdown_duration_days == 0 for r in result.results)
|
||||
|
||||
def test_drawdown_duration_increments(self):
|
||||
"""Drop-then-recover should grow duration while below peak, reset at recovery."""
|
||||
prices = [100.0, 110.0, 105.0, 100.0, 95.0, 120.0]
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": prices,
|
||||
"high": prices,
|
||||
"low": prices,
|
||||
"close": prices,
|
||||
"volume": list(range(len(prices))),
|
||||
},
|
||||
index=pd.date_range(
|
||||
"2021-01-01", periods=len(prices), freq="D", name="date"
|
||||
),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = drawdown(DrawdownQueryParams(data=records))
|
||||
durations = [r.drawdown_duration_days for r in result.results]
|
||||
assert durations == [0, 0, 1, 2, 3, 0]
|
||||
|
||||
def test_defaults_via_params(self, long_records):
|
||||
params = DrawdownQueryParams(data=long_records)
|
||||
assert params.target == "close"
|
||||
|
||||
|
||||
class TestReturnsStats:
|
||||
def test_summary_default(self, realistic_returns_records):
|
||||
result = returns_stats(ReturnsStatsQueryParams(data=realistic_returns_records))
|
||||
assert len(result.results) == 1
|
||||
row = result.results[0]
|
||||
assert isinstance(row, ReturnsStatsData)
|
||||
assert row.sharpe is not None
|
||||
assert row.sortino is not None
|
||||
assert row.calmar is not None
|
||||
assert row.max_drawdown < 0
|
||||
assert row.var_95 is not None
|
||||
assert row.cvar_95 is not None
|
||||
|
||||
def test_rolling_window(self, realistic_returns_records):
|
||||
result = returns_stats(
|
||||
ReturnsStatsQueryParams(data=realistic_returns_records, window=20)
|
||||
)
|
||||
assert len(result.results) > 1
|
||||
assert all(isinstance(r, ReturnsStatsData) for r in result.results)
|
||||
assert result.results[0].date is not None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frequency", ["daily", "weekly", "monthly", "quarterly", "annual"]
|
||||
)
|
||||
def test_each_frequency(self, realistic_returns_records, frequency):
|
||||
result = returns_stats(
|
||||
ReturnsStatsQueryParams(data=realistic_returns_records, frequency=frequency)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_risk_free_rate_flips_excess(self, realistic_returns_records):
|
||||
baseline = returns_stats(
|
||||
ReturnsStatsQueryParams(data=realistic_returns_records, risk_free_rate=0.0)
|
||||
)
|
||||
shifted = returns_stats(
|
||||
ReturnsStatsQueryParams(data=realistic_returns_records, risk_free_rate=0.50)
|
||||
)
|
||||
assert baseline.results[0].sharpe != shifted.results[0].sharpe
|
||||
|
||||
def test_monotone_up_no_sortino_no_calmar(self, monotone_up_records):
|
||||
result = returns_stats(ReturnsStatsQueryParams(data=monotone_up_records))
|
||||
row = result.results[0]
|
||||
assert row.sortino is None
|
||||
assert row.calmar is None
|
||||
|
||||
def test_constant_series(self, constant_records):
|
||||
result = returns_stats(ReturnsStatsQueryParams(data=constant_records))
|
||||
row = result.results[0]
|
||||
assert row.mean_return == pytest.approx(0.0)
|
||||
assert row.std_return == pytest.approx(0.0)
|
||||
assert row.sharpe is None
|
||||
assert row.skew is None
|
||||
assert row.kurtosis is None
|
||||
|
||||
def test_single_row_short_block(self):
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": [100.0],
|
||||
"high": [100.0],
|
||||
"low": [100.0],
|
||||
"close": [100.0],
|
||||
"volume": [1],
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=1, freq="D", name="date"),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = returns_stats(ReturnsStatsQueryParams(data=records))
|
||||
row = result.results[0]
|
||||
assert row.mean_return is None
|
||||
assert row.date is None
|
||||
|
||||
def test_defaults_via_params(self, long_records):
|
||||
params = ReturnsStatsQueryParams(data=long_records)
|
||||
assert params.frequency == "daily"
|
||||
assert params.window is None
|
||||
assert params.risk_free_rate == 0.0
|
||||
|
||||
|
||||
class TestStationarity:
|
||||
def test_random_walk_is_non_stationary(self, random_walk_records):
|
||||
result = stationarity(StationarityQueryParams(data=random_walk_records))
|
||||
row = result.results[0]
|
||||
assert isinstance(row, StationarityData)
|
||||
assert row.overall_verdict == "non_stationary"
|
||||
assert row.adf_verdict == "non_stationary"
|
||||
|
||||
def test_stationary_series(self, stationary_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=stationary_records, regression="c")
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.overall_verdict == "stationary"
|
||||
assert row.adf_verdict == "stationary"
|
||||
assert row.kpss_verdict == "stationary"
|
||||
|
||||
def test_trend_stationary_with_ct(self, trend_stationary_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=trend_stationary_records, regression="ct")
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.overall_verdict == "trend_stationary"
|
||||
|
||||
def test_adf_only(self, random_walk_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=random_walk_records, test="adf")
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.kpss_verdict == "skipped"
|
||||
assert row.adf_verdict in {"stationary", "non_stationary"}
|
||||
|
||||
def test_kpss_only(self, random_walk_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=random_walk_records, test="kpss")
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.adf_verdict == "skipped"
|
||||
assert row.kpss_verdict in {"stationary", "non_stationary"}
|
||||
|
||||
def test_regression_n_routes_kpss_to_c(self, stationary_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=stationary_records, regression="n")
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_inconclusive_branch(self, trend_stationary_records):
|
||||
result = stationarity(
|
||||
StationarityQueryParams(data=trend_stationary_records, regression="ctt")
|
||||
)
|
||||
assert result.results[0].overall_verdict == "inconclusive"
|
||||
|
||||
def test_overall_verdict_adf_only_helper(self):
|
||||
assert _overall_verdict("stationary", None, "c") == "stationary"
|
||||
assert _overall_verdict("non_stationary", None, "c") == "non_stationary"
|
||||
|
||||
def test_overall_verdict_kpss_only_helper(self):
|
||||
assert _overall_verdict(None, "stationary", "c") == "stationary"
|
||||
assert _overall_verdict(None, "non_stationary", "c") == "non_stationary"
|
||||
|
||||
def test_defaults_via_params(self, long_records):
|
||||
params = StationarityQueryParams(data=long_records)
|
||||
assert params.test == "both"
|
||||
assert params.regression == "c"
|
||||
|
||||
|
||||
class TestHurst:
|
||||
def test_rs_random_walk_is_trending(self, random_walk_records):
|
||||
result = hurst(
|
||||
HurstQueryParams(data=random_walk_records, method="rs", max_lag=80)
|
||||
)
|
||||
row = result.results[0]
|
||||
assert isinstance(row, HurstData)
|
||||
assert row.hurst_exponent is not None
|
||||
assert row.interpretation == "trending"
|
||||
assert row.confidence is not None
|
||||
|
||||
def test_dfa_random_walk(self, random_walk_records):
|
||||
result = hurst(
|
||||
HurstQueryParams(data=random_walk_records, method="dfa", max_lag=80)
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.hurst_exponent is not None
|
||||
assert row.interpretation == "trending"
|
||||
|
||||
def test_invalid_lag_range(self, random_walk_records):
|
||||
with pytest.raises(ValueError, match="max_lag must be greater"):
|
||||
hurst(HurstQueryParams(data=random_walk_records, min_lag=10, max_lag=5))
|
||||
|
||||
def test_interpret_helper(self):
|
||||
assert _interpret_hurst(0.7) == "trending"
|
||||
assert _interpret_hurst(0.3) == "mean_reverting"
|
||||
assert _interpret_hurst(0.5) == "random_walk"
|
||||
assert _interpret_hurst(None) == "random_walk"
|
||||
assert _interpret_hurst(float("nan")) == "random_walk"
|
||||
|
||||
def test_rs_too_short_returns_nan(self):
|
||||
h, r2 = _hurst_rs(np.arange(10, dtype=float), 100, 200)
|
||||
assert np.isnan(h) and np.isnan(r2)
|
||||
|
||||
def test_dfa_too_short_returns_nan(self):
|
||||
h, r2 = _hurst_dfa(np.arange(10, dtype=float), 100, 200)
|
||||
assert np.isnan(h) and np.isnan(r2)
|
||||
|
||||
def test_rs_constant_series_returns_nan(self):
|
||||
h, r2 = _hurst_rs(np.ones(500, dtype=float), 2, 10)
|
||||
assert np.isnan(h) and np.isnan(r2)
|
||||
|
||||
def test_dfa_constant_series_returns_nan(self):
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
h, r2 = _hurst_dfa(np.ones(500, dtype=float), 2, 10)
|
||||
assert np.isnan(h) and np.isnan(r2)
|
||||
|
||||
def test_endpoint_propagates_nan(self):
|
||||
"""Trigger the ``h_out`` / ``r2_out`` ``None`` branches on a short series."""
|
||||
y = np.arange(50, dtype=float)
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(50)},
|
||||
index=pd.date_range("2021-01-01", periods=50, freq="D", name="date"),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = hurst(
|
||||
HurstQueryParams(data=records, method="rs", min_lag=100, max_lag=200)
|
||||
)
|
||||
row = result.results[0]
|
||||
assert row.hurst_exponent is None
|
||||
assert row.confidence is None
|
||||
assert row.interpretation == "random_walk"
|
||||
|
||||
def test_defaults_via_params(self, random_walk_records):
|
||||
params = HurstQueryParams(data=random_walk_records)
|
||||
assert params.method == "rs"
|
||||
assert params.min_lag == 2
|
||||
assert params.max_lag == 100
|
||||
|
||||
|
||||
class TestAutocorrelation:
|
||||
def test_default_both(self, random_walk_records):
|
||||
result = autocorrelation(
|
||||
AutocorrelationQueryParams(data=random_walk_records, max_lag=10)
|
||||
)
|
||||
assert len(result.results) == 11
|
||||
assert all(isinstance(r, AutocorrelationData) for r in result.results)
|
||||
row = result.results[0]
|
||||
assert row.lag == 0
|
||||
assert row.acf == pytest.approx(1.0)
|
||||
assert row.pacf == pytest.approx(1.0)
|
||||
assert row.significant is False
|
||||
|
||||
def test_acf_only(self, random_walk_records):
|
||||
result = autocorrelation(
|
||||
AutocorrelationQueryParams(
|
||||
data=random_walk_records, max_lag=10, method="acf"
|
||||
)
|
||||
)
|
||||
row = result.results[1]
|
||||
assert row.acf is not None
|
||||
assert row.pacf is None
|
||||
|
||||
def test_pacf_only(self, random_walk_records):
|
||||
result = autocorrelation(
|
||||
AutocorrelationQueryParams(
|
||||
data=random_walk_records, max_lag=10, method="pacf"
|
||||
)
|
||||
)
|
||||
row = result.results[1]
|
||||
assert row.pacf is not None
|
||||
assert row.acf is None
|
||||
assert row.significant is False
|
||||
|
||||
def test_use_returns_false(self, random_walk_records):
|
||||
result = autocorrelation(
|
||||
AutocorrelationQueryParams(
|
||||
data=random_walk_records, max_lag=10, use_returns=False
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
def test_significant_lag_flag(self):
|
||||
"""A deterministic monotone series produces highly autocorrelated levels."""
|
||||
n = 200
|
||||
y = np.linspace(100.0, 200.0, n)
|
||||
df = pd.DataFrame(
|
||||
{"open": y, "high": y, "low": y, "close": y, "volume": np.arange(n)},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = autocorrelation(
|
||||
AutocorrelationQueryParams(data=records, max_lag=20, use_returns=False)
|
||||
)
|
||||
flags = [r.significant for r in result.results]
|
||||
assert flags[0] is False
|
||||
assert any(flags[1:])
|
||||
|
||||
def test_short_series_raises(self, long_records):
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
autocorrelation(AutocorrelationQueryParams(data=long_records, max_lag=200))
|
||||
|
||||
def test_defaults_via_params(self, long_records):
|
||||
params = AutocorrelationQueryParams(data=long_records)
|
||||
assert params.max_lag == 40
|
||||
assert params.method == "both"
|
||||
assert params.use_returns is True
|
||||
|
||||
|
||||
class TestStatsBlockHelper:
|
||||
"""Direct coverage of ``_stats_block`` edge cases."""
|
||||
|
||||
def test_empty_returns(self):
|
||||
block = _stats_block(pd.Series(dtype="float64"), 252, 0.0)
|
||||
assert block["mean_return"] is None
|
||||
assert block["sharpe"] is None
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for openbb_technical.indicators.structure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.structure import (
|
||||
DemarkData,
|
||||
DemarkQueryParams,
|
||||
FibData,
|
||||
FibQueryParams,
|
||||
PivotPointsData,
|
||||
PivotPointsQueryParams,
|
||||
demark,
|
||||
fib,
|
||||
pivot_points,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
class TestFib:
|
||||
def test_default(self, long_records):
|
||||
result = fib(FibQueryParams(data=long_records, period=50))
|
||||
assert result.results
|
||||
assert all(isinstance(r, FibData) for r in result.results)
|
||||
assert result.results[0].level.endswith("%")
|
||||
|
||||
def test_alternate_period(self, long_records):
|
||||
result = fib(FibQueryParams(data=long_records, period=30))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestDemark:
|
||||
def test_default(self, long_records):
|
||||
result = demark(DemarkQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, DemarkData) for r in result.results)
|
||||
|
||||
def test_show_all_false(self, long_records):
|
||||
result = demark(DemarkQueryParams(data=long_records, show_all=False))
|
||||
assert result.results
|
||||
|
||||
def test_asint_false(self, long_records):
|
||||
result = demark(DemarkQueryParams(data=long_records, asint=False))
|
||||
assert result.results
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = demark(DemarkQueryParams(data=long_records, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestPivotPoints:
|
||||
@pytest.mark.parametrize(
|
||||
"method", ["classic", "fibonacci", "woodie", "camarilla", "demark"]
|
||||
)
|
||||
def test_each_method(self, long_records, method):
|
||||
result = pivot_points(PivotPointsQueryParams(data=long_records, method=method))
|
||||
assert result.results
|
||||
assert all(isinstance(r, PivotPointsData) for r in result.results)
|
||||
row = result.results[0]
|
||||
assert row.pivot is not None
|
||||
if method == "camarilla":
|
||||
assert row.r4 is not None
|
||||
assert row.s4 is not None
|
||||
else:
|
||||
assert row.r4 is None
|
||||
assert row.s4 is None
|
||||
|
||||
@pytest.mark.parametrize("anchor", ["day", "week", "month"])
|
||||
def test_each_anchor(self, long_records, anchor):
|
||||
result = pivot_points(PivotPointsQueryParams(data=long_records, anchor=anchor))
|
||||
assert result.results
|
||||
|
||||
def test_fibonacci_has_no_r4_s4(self, long_records):
|
||||
result = pivot_points(
|
||||
PivotPointsQueryParams(data=long_records, method="fibonacci")
|
||||
)
|
||||
assert all(r.r4 is None and r.s4 is None for r in result.results)
|
||||
|
||||
def test_demark_has_no_r2_s2(self, long_records):
|
||||
result = pivot_points(
|
||||
PivotPointsQueryParams(data=long_records, method="demark")
|
||||
)
|
||||
assert all(r.r2 is None and r.s2 is None for r in result.results)
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
def test_fib_defaults(self, long_records):
|
||||
params = FibQueryParams(data=long_records)
|
||||
assert params.period == 120
|
||||
assert params.close_column == "close"
|
||||
|
||||
def test_demark_defaults(self, long_records):
|
||||
params = DemarkQueryParams(data=long_records)
|
||||
assert params.target == "close"
|
||||
assert params.show_all is True
|
||||
assert params.asint is True
|
||||
|
||||
def test_pivot_points_defaults(self, long_records):
|
||||
params = PivotPointsQueryParams(data=long_records)
|
||||
assert params.method == "classic"
|
||||
assert params.anchor == "day"
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for openbb_technical.indicators.trend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.trend import (
|
||||
AdxData,
|
||||
AdxQueryParams,
|
||||
AroonData,
|
||||
AroonQueryParams,
|
||||
ChoppinessData,
|
||||
ChoppinessQueryParams,
|
||||
DiData,
|
||||
DiQueryParams,
|
||||
MacdData,
|
||||
MacdQueryParams,
|
||||
adx,
|
||||
aroon,
|
||||
choppiness,
|
||||
di,
|
||||
macd,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
class TestMacd:
|
||||
def test_default(self, long_records):
|
||||
result = macd(MacdQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, MacdData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.macd is not None
|
||||
assert first.signal is not None
|
||||
assert first.histogram is not None
|
||||
|
||||
def test_custom_windows(self, long_records):
|
||||
result = macd(MacdQueryParams(data=long_records, fast=5, slow=13, signal=4))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestAdx:
|
||||
def test_default(self, long_records):
|
||||
result = adx(AdxQueryParams(data=long_records, length=14))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AdxData) for r in result.results)
|
||||
assert result.results[0].adx is not None
|
||||
|
||||
def test_custom_scalar_and_drift(self, long_records):
|
||||
result = adx(AdxQueryParams(data=long_records, length=14, scalar=50.0, drift=2))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestDi:
|
||||
def test_default(self, long_records):
|
||||
result = di(DiQueryParams(data=long_records, length=14))
|
||||
assert result.results
|
||||
assert all(isinstance(r, DiData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.plus_di is not None
|
||||
assert first.minus_di is not None
|
||||
assert first.dx is not None
|
||||
|
||||
def test_custom_scalar(self, long_records):
|
||||
result = di(DiQueryParams(data=long_records, length=14, scalar=200.0, drift=1))
|
||||
assert result.results
|
||||
|
||||
def test_dx_handles_zero_total(self):
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
idx = pd.date_range("2021-01-01", periods=50, freq="D", name="date")
|
||||
flat = np.full(50, 10.0)
|
||||
df = pd.DataFrame(
|
||||
{"open": flat, "high": flat, "low": flat, "close": flat, "volume": flat},
|
||||
index=idx,
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = di(DiQueryParams(data=records, length=14))
|
||||
assert result.results or result.results == []
|
||||
|
||||
|
||||
class TestAroon:
|
||||
def test_default(self, long_records):
|
||||
result = aroon(AroonQueryParams(data=long_records, length=25))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AroonData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.aroon_up is not None
|
||||
assert first.aroon_down is not None
|
||||
assert first.aroon_oscillator is not None
|
||||
|
||||
def test_custom_scalar(self, long_records):
|
||||
result = aroon(AroonQueryParams(data=long_records, length=25, scalar=50.0))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestChoppiness:
|
||||
def test_default(self, long_records):
|
||||
result = choppiness(ChoppinessQueryParams(data=long_records, length=14))
|
||||
assert result.results
|
||||
assert all(isinstance(r, ChoppinessData) for r in result.results)
|
||||
assert result.results[0].choppiness is not None
|
||||
|
||||
def test_custom_atr_length_and_scalar(self, long_records):
|
||||
result = choppiness(
|
||||
ChoppinessQueryParams(
|
||||
data=long_records, length=14, atr_length=3, scalar=200.0
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
"""Field-level coverage of the QueryParams classes."""
|
||||
|
||||
def test_macd_defaults(self, long_records):
|
||||
params = MacdQueryParams(data=long_records)
|
||||
assert params.fast == 12
|
||||
assert params.slow == 26
|
||||
assert params.signal == 9
|
||||
assert params.target == "close"
|
||||
|
||||
def test_adx_defaults(self, long_records):
|
||||
params = AdxQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.scalar == 100.0
|
||||
assert params.drift == 1
|
||||
|
||||
def test_di_defaults(self, long_records):
|
||||
params = DiQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.scalar == 100.0
|
||||
assert params.drift == 1
|
||||
|
||||
def test_aroon_defaults(self, long_records):
|
||||
params = AroonQueryParams(data=long_records)
|
||||
assert params.length == 25
|
||||
assert params.scalar == 100.0
|
||||
|
||||
def test_choppiness_defaults(self, long_records):
|
||||
params = ChoppinessQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.atr_length == 1
|
||||
assert params.scalar == 100.0
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for openbb_technical.indicators.volatility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.volatility import (
|
||||
_VOL_FUNCTIONS,
|
||||
AtrData,
|
||||
AtrQueryParams,
|
||||
ConesData,
|
||||
ConesQueryParams,
|
||||
RealizedVolatilityCompareData,
|
||||
RealizedVolatilityCompareQueryParams,
|
||||
RealizedVolatilityData,
|
||||
RealizedVolatilityQueryParams,
|
||||
atr,
|
||||
cones,
|
||||
realized_volatility,
|
||||
realized_volatility_compare,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records_400():
|
||||
"""OHLCV with 400 rows — enough for the longest cones window (360)."""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
base = np.arange(1, 401, dtype="float64")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": base,
|
||||
"high": base + 0.5,
|
||||
"low": base - 0.5,
|
||||
"close": base,
|
||||
"volume": base * 100,
|
||||
},
|
||||
index=pd.date_range("2020-01-01", periods=400, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
class TestRealizedVolatility:
|
||||
@pytest.mark.parametrize("model", list(_VOL_FUNCTIONS.keys()))
|
||||
def test_each_model_returns_data(self, long_records, model):
|
||||
result = realized_volatility(
|
||||
RealizedVolatilityQueryParams(data=long_records, model=model, window=20)
|
||||
)
|
||||
assert result.results
|
||||
assert all(isinstance(r, RealizedVolatilityData) for r in result.results)
|
||||
assert result.results[0].model == model
|
||||
|
||||
def test_is_crypto_uses_365(self, long_records):
|
||||
result = realized_volatility(
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="std", is_crypto=True, window=20
|
||||
)
|
||||
)
|
||||
assert result.results[0].trading_periods == 365
|
||||
|
||||
def test_explicit_trading_periods_override(self, long_records):
|
||||
result = realized_volatility(
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="std", trading_periods=200, window=20
|
||||
)
|
||||
)
|
||||
assert result.results[0].trading_periods == 200
|
||||
|
||||
def test_clean_false_keeps_warmup_rows(self, long_records):
|
||||
cleaned = realized_volatility(
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="std", window=20, clean=True
|
||||
)
|
||||
)
|
||||
raw = realized_volatility(
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="std", window=20, clean=False
|
||||
)
|
||||
)
|
||||
assert len(raw.results) > len(cleaned.results)
|
||||
|
||||
def test_window_below_two_rejected_for_ht(self, long_records):
|
||||
with pytest.raises(ValueError, match="hodges_tompkins requires window"):
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="hodges_tompkins", window=1
|
||||
)
|
||||
|
||||
def test_window_below_two_rejected_for_yz(self, long_records):
|
||||
with pytest.raises(ValueError, match="yang_zhang requires window"):
|
||||
RealizedVolatilityQueryParams(
|
||||
data=long_records, model="yang_zhang", window=1
|
||||
)
|
||||
|
||||
|
||||
class TestRealizedVolatilityCompare:
|
||||
def test_default_includes_all_models(self, long_records):
|
||||
result = realized_volatility_compare(
|
||||
RealizedVolatilityCompareQueryParams(data=long_records, window=20)
|
||||
)
|
||||
row = result.results[0]
|
||||
assert isinstance(row, RealizedVolatilityCompareData)
|
||||
assert row.std is not None
|
||||
assert row.yang_zhang is not None
|
||||
|
||||
def test_subset_of_models(self, long_records):
|
||||
result = realized_volatility_compare(
|
||||
RealizedVolatilityCompareQueryParams(
|
||||
data=long_records, models=["std", "parkinson"], window=20
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
row = result.results[0]
|
||||
assert row.std is not None
|
||||
assert row.parkinson is not None
|
||||
assert row.garman_klass is None
|
||||
|
||||
def test_clean_false_keeps_warmup(self, long_records):
|
||||
cleaned = realized_volatility_compare(
|
||||
RealizedVolatilityCompareQueryParams(
|
||||
data=long_records, window=20, clean=True
|
||||
)
|
||||
)
|
||||
raw = realized_volatility_compare(
|
||||
RealizedVolatilityCompareQueryParams(
|
||||
data=long_records, window=20, clean=False
|
||||
)
|
||||
)
|
||||
assert len(raw.results) >= len(cleaned.results)
|
||||
|
||||
|
||||
class TestCones:
|
||||
def test_default_table(self, long_records_400):
|
||||
result = cones(ConesQueryParams(data=long_records_400))
|
||||
assert result.results
|
||||
assert all(isinstance(r, ConesData) for r in result.results)
|
||||
first = result.results[0]
|
||||
assert first.window > 0
|
||||
assert first.lower is not None
|
||||
assert first.upper is not None
|
||||
|
||||
@pytest.mark.parametrize("model", list(_VOL_FUNCTIONS.keys()))
|
||||
def test_each_model(self, long_records_400, model):
|
||||
result = cones(ConesQueryParams(data=long_records_400, model=model))
|
||||
assert result.results
|
||||
|
||||
def test_is_crypto_branch(self, long_records_400):
|
||||
result = cones(ConesQueryParams(data=long_records_400, is_crypto=True))
|
||||
assert result.results
|
||||
|
||||
def test_explicit_trading_periods(self, long_records_400):
|
||||
result = cones(ConesQueryParams(data=long_records_400, trading_periods=200))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestAtr:
|
||||
def test_default(self, long_records):
|
||||
result = atr(AtrQueryParams(data=long_records, length=14))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AtrData) for r in result.results)
|
||||
assert result.results[0].atr is not None
|
||||
|
||||
@pytest.mark.parametrize("mamode", ["sma", "ema", "wma", "rma"])
|
||||
def test_each_mamode(self, long_records, mamode):
|
||||
result = atr(AtrQueryParams(data=long_records, length=14, mamode=mamode))
|
||||
assert result.results
|
||||
|
||||
def test_drift_and_offset(self, long_records):
|
||||
result = atr(AtrQueryParams(data=long_records, length=14, drift=2, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
"""Field-level coverage of the QueryParams classes."""
|
||||
|
||||
def test_realized_volatility_defaults(self, long_records):
|
||||
params = RealizedVolatilityQueryParams(data=long_records)
|
||||
assert params.model == "yang_zhang"
|
||||
assert params.window == 30
|
||||
assert params.is_crypto is False
|
||||
|
||||
def test_compare_defaults_include_all_models(self, long_records):
|
||||
params = RealizedVolatilityCompareQueryParams(data=long_records)
|
||||
assert set(params.models) == set(_VOL_FUNCTIONS.keys())
|
||||
|
||||
def test_cones_defaults(self, long_records_400):
|
||||
params = ConesQueryParams(data=long_records_400)
|
||||
assert params.lower_q == 0.25
|
||||
assert params.upper_q == 0.75
|
||||
assert params.model == "std"
|
||||
|
||||
def test_atr_defaults(self, long_records):
|
||||
params = AtrQueryParams(data=long_records)
|
||||
assert params.length == 14
|
||||
assert params.mamode == "rma"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Tests for openbb_technical.indicators.volume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.indicators.volume import (
|
||||
AdData,
|
||||
AdoscData,
|
||||
AdoscQueryParams,
|
||||
AdQueryParams,
|
||||
ObvData,
|
||||
ObvQueryParams,
|
||||
VwapData,
|
||||
VwapQueryParams,
|
||||
ad,
|
||||
adosc,
|
||||
obv,
|
||||
vwap,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Convert the standard OHLCV fixture into ``list[Data]``."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
class TestObv:
|
||||
def test_default(self, long_records):
|
||||
result = obv(ObvQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, ObvData) for r in result.results)
|
||||
assert result.results[0].obv is not None
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = obv(ObvQueryParams(data=long_records, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestAd:
|
||||
def test_default(self, long_records):
|
||||
result = ad(AdQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AdData) for r in result.results)
|
||||
assert result.results[0].ad is not None
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = ad(AdQueryParams(data=long_records, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestAdosc:
|
||||
def test_default(self, long_records):
|
||||
result = adosc(AdoscQueryParams(data=long_records, fast=3, slow=10))
|
||||
assert result.results
|
||||
assert all(isinstance(r, AdoscData) for r in result.results)
|
||||
assert result.results[0].adosc is not None
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = adosc(AdoscQueryParams(data=long_records, fast=3, slow=10, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestVwap:
|
||||
def test_default(self, long_records):
|
||||
result = vwap(VwapQueryParams(data=long_records))
|
||||
assert result.results
|
||||
assert all(isinstance(r, VwapData) for r in result.results)
|
||||
assert result.results[0].vwap is not None
|
||||
|
||||
@pytest.mark.parametrize("anchor", ["D", "W"])
|
||||
def test_each_anchor(self, long_records, anchor):
|
||||
result = vwap(VwapQueryParams(data=long_records, anchor=anchor))
|
||||
assert result.results
|
||||
|
||||
def test_offset(self, long_records):
|
||||
result = vwap(VwapQueryParams(data=long_records, offset=1))
|
||||
assert result.results
|
||||
|
||||
|
||||
class TestQueryParamModels:
|
||||
def test_obv_defaults(self, long_records):
|
||||
params = ObvQueryParams(data=long_records)
|
||||
assert params.index == "date"
|
||||
assert params.offset == 0
|
||||
|
||||
def test_ad_defaults(self, long_records):
|
||||
params = AdQueryParams(data=long_records)
|
||||
assert params.offset == 0
|
||||
|
||||
def test_adosc_defaults(self, long_records):
|
||||
params = AdoscQueryParams(data=long_records)
|
||||
assert params.fast == 3
|
||||
assert params.slow == 10
|
||||
|
||||
def test_vwap_defaults(self, long_records):
|
||||
params = VwapQueryParams(data=long_records)
|
||||
assert params.anchor == "D"
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for ``openbb_technical.multi``."""
|
||||
44
openbb_platform/extensions/technical/tests/multi/conftest.py
Normal file
44
openbb_platform/extensions/technical/tests/multi/conftest.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Shared fixtures for the ``openbb_technical.multi`` tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def multi_symbol_records() -> list:
|
||||
"""Long-format ``list[Data]`` with 3 symbols x 300 daily bars.
|
||||
|
||||
The series are independent geometric random walks so correlations are
|
||||
finite but non-trivial, and indicators have enough rows to warm up.
|
||||
"""
|
||||
periods = 300
|
||||
rng = np.random.default_rng(42)
|
||||
dates = pd.date_range("2021-01-01", periods=periods, freq="D", name="date")
|
||||
frames: list[pd.DataFrame] = []
|
||||
for symbol, drift in [("AAA", 0.001), ("BBB", 0.0005), ("CCC", -0.0005)]:
|
||||
rets = rng.normal(loc=drift, scale=0.02, size=periods)
|
||||
close = 100.0 * np.exp(np.cumsum(rets))
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"date": dates,
|
||||
"symbol": symbol,
|
||||
"open": close * (1 + rng.normal(0, 0.001, periods)),
|
||||
"high": close * (1 + np.abs(rng.normal(0, 0.005, periods))),
|
||||
"low": close * (1 - np.abs(rng.normal(0, 0.005, periods))),
|
||||
"close": close,
|
||||
"volume": rng.integers(1_000_000, 5_000_000, periods).astype(float),
|
||||
}
|
||||
)
|
||||
frames.append(df)
|
||||
big = pd.concat(frames, ignore_index=True)
|
||||
return df_to_basemodel(big)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def single_symbol_records(multi_symbol_records: list) -> list:
|
||||
"""Single-symbol slice (AAA only) for compose/screen tests."""
|
||||
return [r for r in multi_symbol_records if r.symbol == "AAA"]
|
||||
262
openbb_platform/extensions/technical/tests/multi/test_catalog.py
Normal file
262
openbb_platform/extensions/technical/tests/multi/test_catalog.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Tests for ``openbb_technical.multi.catalog``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from pydantic import Field
|
||||
|
||||
from openbb_technical.multi.catalog import (
|
||||
_CATALOG,
|
||||
IndicatorEntry,
|
||||
IndicatorOutputColumn,
|
||||
IndicatorParam,
|
||||
IndicatorsQueryParams,
|
||||
IndicatorsResponse,
|
||||
_build_catalog,
|
||||
_catalog,
|
||||
_default_for,
|
||||
_example_call,
|
||||
_field_constraints,
|
||||
_find_data_model,
|
||||
_literal_choices,
|
||||
_match_query_params_for,
|
||||
_module_query_params,
|
||||
_output_column_entries,
|
||||
_required_data_columns,
|
||||
_stringify_type,
|
||||
indicators,
|
||||
)
|
||||
|
||||
|
||||
class TestStringifyType:
|
||||
def test_basic(self):
|
||||
assert _stringify_type(int) == "<class 'int'>"
|
||||
|
||||
def test_optional_literal(self):
|
||||
assert "Literal" in _stringify_type(Literal["a", "b"])
|
||||
|
||||
|
||||
class TestLiteralChoices:
|
||||
def test_direct(self):
|
||||
assert _literal_choices(Literal["a", "b"]) == ["a", "b"]
|
||||
|
||||
def test_nested(self):
|
||||
assert _literal_choices(Literal["x"] | None) == ["x"]
|
||||
|
||||
def test_none(self):
|
||||
assert _literal_choices(int) is None
|
||||
|
||||
|
||||
class TestFieldConstraints:
|
||||
def test_extracts_gt(self):
|
||||
class _Q(QueryParams):
|
||||
x: int = Field(default=1, gt=0)
|
||||
|
||||
out = _field_constraints(_Q.model_fields["x"])
|
||||
assert out is not None
|
||||
assert out.get("gt") == 0
|
||||
|
||||
def test_returns_none_when_empty(self):
|
||||
class _Q(QueryParams):
|
||||
x: int = 1
|
||||
|
||||
assert _field_constraints(_Q.model_fields["x"]) is None
|
||||
|
||||
def test_extracts_direct_attribute(self):
|
||||
"""Cover the Pydantic-V1-style direct attribute branch."""
|
||||
|
||||
class _Stub:
|
||||
gt = 5
|
||||
metadata = ()
|
||||
|
||||
out = _field_constraints(_Stub()) # type: ignore[arg-type]
|
||||
assert out == {"gt": 5}
|
||||
|
||||
|
||||
class TestDefaultFor:
|
||||
def test_default(self):
|
||||
class _Q(QueryParams):
|
||||
x: int = 7
|
||||
|
||||
assert _default_for(_Q.model_fields["x"]) == 7
|
||||
|
||||
def test_required(self):
|
||||
class _Q(QueryParams):
|
||||
x: int = Field()
|
||||
|
||||
assert _default_for(_Q.model_fields["x"]) is None
|
||||
|
||||
def test_factory(self):
|
||||
class _Q(QueryParams):
|
||||
x: list[int] = Field(default_factory=lambda: [1, 2])
|
||||
|
||||
assert _default_for(_Q.model_fields["x"]) == [1, 2]
|
||||
|
||||
|
||||
class TestModuleHelpers:
|
||||
def test_module_query_params_finds_classes(self):
|
||||
from openbb_technical.indicators import volatility as mod
|
||||
|
||||
candidates = _module_query_params(mod)
|
||||
assert "RealizedVolatilityQueryParams" in candidates
|
||||
|
||||
def test_match_query_params(self):
|
||||
candidates = {"RsiQueryParams": object, "MacdQueryParams": object}
|
||||
assert (
|
||||
_match_query_params_for("rsi", candidates) is candidates["RsiQueryParams"]
|
||||
)
|
||||
|
||||
def test_match_query_params_missing(self):
|
||||
assert _match_query_params_for("nope", {}) is None
|
||||
|
||||
def test_find_data_model(self):
|
||||
from openbb_technical.indicators import volatility as mod
|
||||
|
||||
assert _find_data_model(mod, "atr") is mod.AtrData
|
||||
|
||||
def test_find_data_model_missing(self):
|
||||
from openbb_technical.indicators import volatility as mod
|
||||
|
||||
assert _find_data_model(mod, "not_an_endpoint") is None
|
||||
|
||||
|
||||
class TestOutputColumns:
|
||||
def test_columns_from_data_model(self):
|
||||
from openbb_technical.indicators.volatility import (
|
||||
AtrData,
|
||||
AtrQueryParams,
|
||||
)
|
||||
|
||||
cols = _output_column_entries(AtrQueryParams, AtrData)
|
||||
names = [c.name for c in cols]
|
||||
assert "date" in names
|
||||
assert "atr" in names
|
||||
|
||||
def test_columns_without_data_model(self):
|
||||
class _Q(QueryParams):
|
||||
__category__ = "test"
|
||||
__output_columns__ = ("a", "b")
|
||||
|
||||
cols = _output_column_entries(_Q, None)
|
||||
assert [c.name for c in cols] == ["a", "b"]
|
||||
|
||||
|
||||
class TestRequiredDataColumns:
|
||||
def test_picks_up_mentions(self):
|
||||
class _Q(QueryParams):
|
||||
"""Needs the high and low columns."""
|
||||
|
||||
assert "high" in _required_data_columns(_Q)
|
||||
assert "low" in _required_data_columns(_Q)
|
||||
|
||||
|
||||
class TestExampleCall:
|
||||
def test_includes_defaults(self):
|
||||
from openbb_technical.indicators.volatility import RealizedVolatilityQueryParams
|
||||
|
||||
ex = _example_call("realized_volatility", RealizedVolatilityQueryParams)
|
||||
assert ex["endpoint"] == "realized_volatility"
|
||||
assert ex["window"] == 30
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
def test_non_empty(self):
|
||||
assert _CATALOG, "catalog should not be empty"
|
||||
|
||||
def test_index_access(self):
|
||||
first = _CATALOG[0]
|
||||
assert isinstance(first, IndicatorEntry)
|
||||
|
||||
def test_catalog_helper_returns_cached(self):
|
||||
first = _catalog()
|
||||
second = _catalog()
|
||||
assert first is second
|
||||
|
||||
def test_endpoint_without_query_params_is_skipped(self, monkeypatch):
|
||||
"""When an endpoint has no ``XxxQueryParams`` sibling it is skipped."""
|
||||
from openbb_core.app.router import Router
|
||||
|
||||
from openbb_technical.multi import catalog as catalog_module
|
||||
|
||||
skip_router = Router(prefix="", description="")
|
||||
|
||||
@skip_router.command(methods=["POST"])
|
||||
def lonely_endpoint(data: list = []) -> dict: # noqa: B006
|
||||
return {} # pragma: no cover - never called
|
||||
|
||||
stub_module = type(
|
||||
"stub_mod",
|
||||
(),
|
||||
{"__name__": "stub_mod", "router": skip_router},
|
||||
)
|
||||
|
||||
original = catalog_module._FAMILY_MODULES
|
||||
monkeypatch.setattr(catalog_module, "_FAMILY_MODULES", ("stub_mod",))
|
||||
|
||||
import sys
|
||||
|
||||
sys.modules["stub_mod"] = stub_module # type: ignore[assignment]
|
||||
try:
|
||||
built = catalog_module._build_catalog()
|
||||
finally:
|
||||
sys.modules.pop("stub_mod", None)
|
||||
monkeypatch.setattr(catalog_module, "_FAMILY_MODULES", original)
|
||||
|
||||
assert built == []
|
||||
|
||||
def test_every_entry_populated(self):
|
||||
for entry in _CATALOG:
|
||||
assert isinstance(entry, IndicatorEntry)
|
||||
assert entry.name
|
||||
assert entry.category
|
||||
assert isinstance(entry.params, list)
|
||||
assert isinstance(entry.output_columns, list)
|
||||
assert isinstance(entry.example_call, dict)
|
||||
for p in entry.params:
|
||||
assert isinstance(p, IndicatorParam)
|
||||
assert p.name
|
||||
assert p.type
|
||||
for c in entry.output_columns:
|
||||
assert isinstance(c, IndicatorOutputColumn)
|
||||
assert c.name
|
||||
assert c.type
|
||||
|
||||
def test_rebuild_idempotent(self):
|
||||
rebuilt = _build_catalog()
|
||||
names = sorted(e.name for e in _CATALOG)
|
||||
rebuilt_names = sorted(e.name for e in rebuilt)
|
||||
assert names == rebuilt_names
|
||||
|
||||
|
||||
class TestIndicatorsEndpoint:
|
||||
def test_all(self):
|
||||
out = indicators(IndicatorsQueryParams(category="all"))
|
||||
assert isinstance(out.results, IndicatorsResponse)
|
||||
assert out.results.indicators
|
||||
|
||||
def test_filter_volatility(self):
|
||||
out = indicators(IndicatorsQueryParams(category="volatility"))
|
||||
assert all(e.category == "volatility" for e in out.results.indicators)
|
||||
names = {e.name for e in out.results.indicators}
|
||||
assert "atr" in names
|
||||
|
||||
def test_filter_none_treats_as_all(self):
|
||||
out = indicators(IndicatorsQueryParams(category=None))
|
||||
assert len(out.results.indicators) == len(_CATALOG)
|
||||
|
||||
def test_filter_no_match(self):
|
||||
params = IndicatorsQueryParams(category="all")
|
||||
assert params.category == "all"
|
||||
|
||||
|
||||
class TestQueryParams:
|
||||
def test_defaults(self):
|
||||
params = IndicatorsQueryParams()
|
||||
assert params.category == "all"
|
||||
|
||||
def test_invalid_category(self):
|
||||
with pytest.raises(Exception):
|
||||
IndicatorsQueryParams(category="not_a_category")
|
||||
126
openbb_platform/extensions/technical/tests/multi/test_compose.py
Normal file
126
openbb_platform/extensions/technical/tests/multi/test_compose.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Tests for ``openbb_technical.multi.compose``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openbb_technical.multi.compose import (
|
||||
MultiIndicatorRequest,
|
||||
MultiQueryParams,
|
||||
MultiResultRow,
|
||||
_numeric_columns,
|
||||
_resolve_indicator,
|
||||
_row_key,
|
||||
_to_records,
|
||||
multi,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveIndicator:
|
||||
def test_resolves_known(self):
|
||||
fn = _resolve_indicator("rsi")
|
||||
assert callable(fn)
|
||||
|
||||
def test_skip_on_missing(self):
|
||||
assert _resolve_indicator("___definitely_not_a_real_indicator___") is None
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_to_records_passthrough_dict(self):
|
||||
rows = _to_records([{"a": 1}])
|
||||
assert rows == [{"a": 1}]
|
||||
|
||||
def test_to_records_from_pydantic(self):
|
||||
class _Row:
|
||||
def model_dump(self):
|
||||
return {"a": 2}
|
||||
|
||||
assert _to_records([_Row()]) == [{"a": 2}]
|
||||
|
||||
def test_to_records_unwraps_obbject(self):
|
||||
class _OBB:
|
||||
results = [{"a": 3}]
|
||||
|
||||
assert _to_records(_OBB()) == [{"a": 3}]
|
||||
|
||||
def test_row_key_uses_index(self):
|
||||
assert _row_key({"date": "2021-01-01", "x": 1}, "date") == "2021-01-01"
|
||||
|
||||
def test_numeric_columns_drops_ohlc(self):
|
||||
row = {
|
||||
"date": "2021-01-01",
|
||||
"open": 1.0,
|
||||
"high": 1.0,
|
||||
"low": 1.0,
|
||||
"close": 1.0,
|
||||
"volume": 1.0,
|
||||
"rsi": 55.0,
|
||||
"label": "tag",
|
||||
}
|
||||
out = _numeric_columns(row, target="close")
|
||||
assert "rsi" in out
|
||||
assert "label" not in out
|
||||
assert "open" not in out
|
||||
|
||||
def test_numeric_columns_passes_none(self):
|
||||
row = {"rsi": None}
|
||||
out = _numeric_columns(row, target="close")
|
||||
assert out == {"rsi": None}
|
||||
|
||||
|
||||
class TestMultiEndpoint:
|
||||
def test_single_indicator(self, single_symbol_records):
|
||||
out = multi(
|
||||
MultiQueryParams(
|
||||
data=single_symbol_records,
|
||||
indicators=[
|
||||
MultiIndicatorRequest(indicator="rsi", params={"length": 14})
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
assert all(isinstance(r, MultiResultRow) for r in out.results)
|
||||
assert any(r.values.get("rsi.rsi") is not None for r in out.results)
|
||||
|
||||
def test_two_indicators_merge(self, single_symbol_records):
|
||||
out = multi(
|
||||
MultiQueryParams(
|
||||
data=single_symbol_records,
|
||||
indicators=[
|
||||
MultiIndicatorRequest(indicator="rsi", params={"length": 14}),
|
||||
MultiIndicatorRequest(indicator="atr", params={"length": 14}),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
for row in out.results:
|
||||
if "rsi.rsi" in row.values and "atr.atr" in row.values:
|
||||
assert row.values["rsi.rsi"] is not None
|
||||
assert row.values["atr.atr"] is not None
|
||||
break
|
||||
else: # pragma: no cover - sanity guard, fixture has 300 bars
|
||||
pytest.fail("Never found a row where both indicators warmed up.")
|
||||
|
||||
def test_unknown_indicator_skipped(self, single_symbol_records):
|
||||
out = multi(
|
||||
MultiQueryParams(
|
||||
data=single_symbol_records,
|
||||
indicators=[
|
||||
MultiIndicatorRequest(indicator="not_a_real_indicator", params={}),
|
||||
MultiIndicatorRequest(indicator="rsi", params={"length": 14}),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
assert all(
|
||||
all(not k.startswith("not_a_real_indicator.") for k in r.values)
|
||||
for r in out.results
|
||||
)
|
||||
|
||||
def test_query_params_defaults(self, single_symbol_records):
|
||||
params = MultiQueryParams(
|
||||
data=single_symbol_records,
|
||||
indicators=[MultiIndicatorRequest(indicator="rsi")],
|
||||
)
|
||||
assert params.index == "date"
|
||||
assert params.target == "close"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for ``openbb_technical.multi.correlation``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openbb_technical.multi.correlation import (
|
||||
CorrelationData,
|
||||
CorrelationMatrixData,
|
||||
CorrelationMatrixQueryParams,
|
||||
CorrelationQueryParams,
|
||||
correlation,
|
||||
correlation_matrix,
|
||||
)
|
||||
|
||||
|
||||
class TestCorrelation:
|
||||
def test_default_pairs_pearson(self, multi_symbol_records):
|
||||
out = correlation(CorrelationQueryParams(data=multi_symbol_records, window=30))
|
||||
assert out.results
|
||||
assert all(isinstance(r, CorrelationData) for r in out.results)
|
||||
pair_set = {(r.symbol_a, r.symbol_b) for r in out.results}
|
||||
assert pair_set == {("AAA", "BBB"), ("AAA", "CCC"), ("BBB", "CCC")}
|
||||
|
||||
def test_explicit_pairs(self, multi_symbol_records):
|
||||
out = correlation(
|
||||
CorrelationQueryParams(
|
||||
data=multi_symbol_records,
|
||||
window=30,
|
||||
pairs=[("AAA", "BBB"), ("XYZ", "AAA")],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
pair_set = {(r.symbol_a, r.symbol_b) for r in out.results}
|
||||
assert pair_set == {("AAA", "BBB")}
|
||||
|
||||
@pytest.mark.parametrize("method", ["pearson", "spearman", "kendall"])
|
||||
def test_each_method(self, multi_symbol_records, method):
|
||||
out = correlation(
|
||||
CorrelationQueryParams(data=multi_symbol_records, window=30, method=method)
|
||||
)
|
||||
assert out.results
|
||||
assert any(r.correlation is not None for r in out.results)
|
||||
|
||||
def test_missing_symbol_column_errors(self, single_symbol_records):
|
||||
sym_less = [
|
||||
type(r).model_validate(
|
||||
{k: v for k, v in r.model_dump().items() if k != "symbol"}
|
||||
)
|
||||
for r in single_symbol_records[:5]
|
||||
]
|
||||
with pytest.raises(ValueError, match="symbol"):
|
||||
correlation(CorrelationQueryParams(data=sym_less, window=5))
|
||||
|
||||
def test_query_params_defaults(self, multi_symbol_records):
|
||||
params = CorrelationQueryParams(data=multi_symbol_records)
|
||||
assert params.window == 60
|
||||
assert params.method == "pearson"
|
||||
assert params.target == "close"
|
||||
assert params.pairs is None
|
||||
|
||||
|
||||
class TestCorrelationMatrix:
|
||||
def test_default_uses_last_date(self, multi_symbol_records):
|
||||
out = correlation_matrix(
|
||||
CorrelationMatrixQueryParams(data=multi_symbol_records)
|
||||
)
|
||||
assert out.results
|
||||
row = out.results[0]
|
||||
assert isinstance(row, CorrelationMatrixData)
|
||||
assert row.symbols == ["AAA", "BBB", "CCC"]
|
||||
assert row.matrix[0][0] == pytest.approx(1.0)
|
||||
assert row.matrix[1][1] == pytest.approx(1.0)
|
||||
|
||||
def test_window_clipped(self, multi_symbol_records):
|
||||
out = correlation_matrix(
|
||||
CorrelationMatrixQueryParams(data=multi_symbol_records, window=60)
|
||||
)
|
||||
assert out.results
|
||||
assert len(out.results[0].symbols) == 3
|
||||
|
||||
def test_explicit_as_of(self, multi_symbol_records):
|
||||
out = correlation_matrix(
|
||||
CorrelationMatrixQueryParams(
|
||||
data=multi_symbol_records, window=60, as_of_date="2021-12-31"
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
assert str(out.results[0].as_of_date) <= "2021-12-31"
|
||||
|
||||
@pytest.mark.parametrize("method", ["pearson", "spearman", "kendall"])
|
||||
def test_each_method(self, multi_symbol_records, method):
|
||||
out = correlation_matrix(
|
||||
CorrelationMatrixQueryParams(data=multi_symbol_records, method=method)
|
||||
)
|
||||
assert out.results
|
||||
|
||||
def test_missing_symbol_column_errors(self, single_symbol_records):
|
||||
sym_less = [
|
||||
type(r).model_validate(
|
||||
{k: v for k, v in r.model_dump().items() if k != "symbol"}
|
||||
)
|
||||
for r in single_symbol_records[:5]
|
||||
]
|
||||
with pytest.raises(ValueError, match="symbol"):
|
||||
correlation_matrix(CorrelationMatrixQueryParams(data=sym_less))
|
||||
|
||||
def test_query_params_defaults(self, multi_symbol_records):
|
||||
params = CorrelationMatrixQueryParams(data=multi_symbol_records)
|
||||
assert params.window is None
|
||||
assert params.method == "pearson"
|
||||
assert params.as_of_date is None
|
||||
254
openbb_platform/extensions/technical/tests/multi/test_screen.py
Normal file
254
openbb_platform/extensions/technical/tests/multi/test_screen.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Tests for ``openbb_technical.multi.screen``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openbb_technical.multi.screen import (
|
||||
ScreenCondition,
|
||||
ScreenMatch,
|
||||
ScreenQueryParams,
|
||||
_evaluate,
|
||||
_find_column,
|
||||
_normalise_value,
|
||||
screen,
|
||||
)
|
||||
|
||||
|
||||
class TestNormaliseValue:
|
||||
def test_scalar(self):
|
||||
assert _normalise_value(50) == 50.0
|
||||
|
||||
def test_tuple(self):
|
||||
assert _normalise_value((1, 2)) == (1.0, 2.0)
|
||||
|
||||
def test_list_to_tuple(self):
|
||||
assert _normalise_value([3, 4]) == (3.0, 4.0)
|
||||
|
||||
|
||||
class TestFindColumn:
|
||||
def test_exact(self):
|
||||
assert _find_column({"rsi": 1}, "rsi") == "rsi"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _find_column({"RSI": 1}, "rsi") == "RSI"
|
||||
|
||||
def test_missing(self):
|
||||
assert _find_column({"rsi": 1}, "macd") is None
|
||||
|
||||
|
||||
class TestEvaluate:
|
||||
series = [
|
||||
("d1", 10.0),
|
||||
("d2", 20.0),
|
||||
("d3", 30.0),
|
||||
("d4", 25.0),
|
||||
("d5", 40.0),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"op,value,fired",
|
||||
[
|
||||
("gt", 35, True),
|
||||
("gte", 40, True),
|
||||
("lt", 50, True),
|
||||
("lte", 40, True),
|
||||
("eq", 40, True),
|
||||
("gt", 50, False),
|
||||
],
|
||||
)
|
||||
def test_simple_ops(self, op, value, fired):
|
||||
result, _ = _evaluate(self.series, op, float(value), len(self.series) - 1)
|
||||
assert result is fired
|
||||
|
||||
def test_between(self):
|
||||
fired, observed = _evaluate(self.series, "between", (35.0, 45.0), 4)
|
||||
assert fired is True
|
||||
assert observed == 40.0
|
||||
|
||||
def test_crossed_above(self):
|
||||
fired, _ = _evaluate(self.series, "crossed_above", (15.0, 5), 4)
|
||||
assert fired is True
|
||||
|
||||
def test_crossed_below(self):
|
||||
fired, _ = _evaluate(self.series, "crossed_below", (28.0, 4), 4)
|
||||
assert fired is True
|
||||
|
||||
def test_made_high(self):
|
||||
fired, _ = _evaluate(self.series, "made_high", (0.0, 3), 4)
|
||||
assert fired is True
|
||||
|
||||
def test_made_low(self):
|
||||
fired, _ = _evaluate(
|
||||
[("d1", 10), ("d2", 5), ("d3", 4)], "made_low", (0.0, 3), 2
|
||||
)
|
||||
assert fired is True
|
||||
|
||||
def test_none_latest(self):
|
||||
fired, _ = _evaluate([("d1", None)], "gt", 0.0, 0)
|
||||
assert fired is False
|
||||
|
||||
def test_event_op_too_few_points(self):
|
||||
fired, _ = _evaluate(self.series, "crossed_above", (15.0, 1), 4)
|
||||
assert fired is False
|
||||
|
||||
|
||||
class TestScreenEndpoint:
|
||||
def test_and_combine_default(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="rsi",
|
||||
operator="gt",
|
||||
value=0,
|
||||
indicator_params={"length": 14},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
assert all(isinstance(r, ScreenMatch) for r in out.results)
|
||||
assert {m.symbol for m in out.results} == {"AAA", "BBB", "CCC"}
|
||||
|
||||
def test_or_combine(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
combine="or",
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="rsi",
|
||||
operator="gt",
|
||||
value=10000,
|
||||
indicator_params={"length": 14},
|
||||
),
|
||||
ScreenCondition(
|
||||
indicator="atr",
|
||||
column="atr",
|
||||
operator="gt",
|
||||
value=0,
|
||||
indicator_params={"length": 14},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
assert {m.symbol for m in out.results} == {"AAA", "BBB", "CCC"}
|
||||
|
||||
def test_unknown_indicator_skipped(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="not_a_real_indicator",
|
||||
column="x",
|
||||
operator="gt",
|
||||
value=0,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results == []
|
||||
|
||||
def test_unknown_column_skipped(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="not_a_column",
|
||||
operator="gt",
|
||||
value=0,
|
||||
indicator_params={"length": 14},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results == []
|
||||
|
||||
def test_as_of_date(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
as_of_date="2021-06-30",
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="rsi",
|
||||
operator="gt",
|
||||
value=0,
|
||||
indicator_params={"length": 14},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results
|
||||
assert all(str(m.as_of_date) <= "2021-06-30" for m in out.results)
|
||||
|
||||
def test_as_of_before_data_skips_symbol(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
as_of_date="1990-01-01",
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="rsi",
|
||||
operator="gt",
|
||||
value=0,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert out.results == []
|
||||
|
||||
def test_missing_symbol_column_errors(self, single_symbol_records):
|
||||
sym_less = [
|
||||
type(r).model_validate(
|
||||
{k: v for k, v in r.model_dump().items() if k != "symbol"}
|
||||
)
|
||||
for r in single_symbol_records[:5]
|
||||
]
|
||||
with pytest.raises(ValueError, match="symbol"):
|
||||
screen(
|
||||
ScreenQueryParams(
|
||||
data=sym_less,
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi", column="rsi", operator="gt", value=0
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def test_event_op_threshold(self, multi_symbol_records):
|
||||
out = screen(
|
||||
ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
conditions=[
|
||||
ScreenCondition(
|
||||
indicator="rsi",
|
||||
column="rsi",
|
||||
operator="crossed_above",
|
||||
value=(50.0, 30),
|
||||
indicator_params={"length": 14},
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
assert isinstance(out.results, list)
|
||||
|
||||
def test_query_params_defaults(self, multi_symbol_records):
|
||||
params = ScreenQueryParams(
|
||||
data=multi_symbol_records,
|
||||
conditions=[
|
||||
ScreenCondition(indicator="rsi", column="rsi", operator="gt", value=0)
|
||||
],
|
||||
)
|
||||
assert params.combine == "and"
|
||||
assert params.as_of_date is None
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for openbb_technical.signals.breakouts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.breakouts import (
|
||||
BreakoutEvent,
|
||||
BreakoutsQueryParams,
|
||||
_channel_bands,
|
||||
breakouts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Monotone OHLCV fixture — every bar is a new high (always breaking upside)."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regime_records() -> list:
|
||||
"""OHLCV that produces both upside and downside breakouts.
|
||||
|
||||
Builds a flat base, a rally that punches above the channel, a sideways
|
||||
pause, a sell-off through the lower band, then another flat period.
|
||||
"""
|
||||
n = 80
|
||||
prices = np.full(n, 100.0)
|
||||
prices[10:25] = np.linspace(100, 150, 15)
|
||||
prices[25:40] = 150.0
|
||||
prices[40:55] = np.linspace(150, 70, 15)
|
||||
prices[55:] = 70.0
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": prices,
|
||||
"high": prices + 1,
|
||||
"low": prices - 1,
|
||||
"close": prices,
|
||||
"volume": np.full(n, 1000.0),
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def flat_records() -> list:
|
||||
"""A flat series — should produce zero breakouts under either method."""
|
||||
n = 60
|
||||
prices = np.full(n, 100.0)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": prices,
|
||||
"high": prices + 0.01,
|
||||
"low": prices - 0.01,
|
||||
"close": prices,
|
||||
"volume": np.full(n, 1000.0),
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
class TestMethodBranches:
|
||||
@pytest.mark.parametrize("method", ["donchian", "bollinger"])
|
||||
def test_each_method_returns_breakout_events(self, regime_records, method):
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method=method, length=20)
|
||||
)
|
||||
assert result.results
|
||||
assert all(isinstance(r, BreakoutEvent) for r in result.results)
|
||||
|
||||
def test_bollinger_uses_default_std_when_not_given(self, regime_records):
|
||||
default = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method="bollinger", length=20)
|
||||
)
|
||||
explicit = breakouts(
|
||||
BreakoutsQueryParams(
|
||||
data=regime_records, method="bollinger", length=20, band_std=2.0
|
||||
)
|
||||
)
|
||||
assert len(default.results) == len(explicit.results)
|
||||
|
||||
def test_bollinger_smaller_std_yields_more_breakouts(self, regime_records):
|
||||
wide = breakouts(
|
||||
BreakoutsQueryParams(
|
||||
data=regime_records, method="bollinger", length=20, band_std=3.0
|
||||
)
|
||||
)
|
||||
narrow = breakouts(
|
||||
BreakoutsQueryParams(
|
||||
data=regime_records, method="bollinger", length=20, band_std=1.0
|
||||
)
|
||||
)
|
||||
assert len(narrow.results) > len(wide.results)
|
||||
|
||||
|
||||
class TestSparseEmission:
|
||||
def test_flat_series_emits_no_events(self, flat_records):
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=flat_records, method="donchian", length=20)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_flat_series_emits_no_events_bollinger(self, flat_records):
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=flat_records, method="bollinger", length=20)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_events_are_sparse_subset_of_bars(self, regime_records):
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
|
||||
)
|
||||
assert 0 < len(result.results) < len(regime_records)
|
||||
|
||||
|
||||
class TestEventFields:
|
||||
def test_upside_direction(self, long_records):
|
||||
"""A strictly monotone-rising series produces only upside breakouts."""
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=long_records, method="donchian", length=20)
|
||||
)
|
||||
assert result.results
|
||||
assert all(r.direction == "upside" for r in result.results)
|
||||
assert all(r.magnitude > 0 for r in result.results)
|
||||
assert all(r.price > r.band for r in result.results)
|
||||
|
||||
def test_downside_direction(self, regime_records):
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
|
||||
)
|
||||
downsides = [r for r in result.results if r.direction == "downside"]
|
||||
assert downsides
|
||||
assert all(r.magnitude < 0 for r in downsides)
|
||||
assert all(r.price < r.band for r in downsides)
|
||||
|
||||
def test_bars_in_range_monotone_for_first_event(self, regime_records):
|
||||
"""``bars_in_range`` on the first event equals the bar index."""
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
|
||||
)
|
||||
first = result.results[0]
|
||||
assert first.bars_in_range >= 0
|
||||
|
||||
def test_bars_in_range_is_inter_event_gap(self, regime_records):
|
||||
"""For each event after the first, bars_in_range = gap to prior event."""
|
||||
result = breakouts(
|
||||
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
|
||||
)
|
||||
assert len(result.results) >= 2
|
||||
for r in result.results[1:]:
|
||||
assert r.bars_in_range >= 1
|
||||
|
||||
|
||||
class TestChannelBandsDispatcher:
|
||||
@pytest.mark.parametrize("method", ["donchian", "bollinger"])
|
||||
def test_each_method_returns_two_series(self, ohlcv_df, method):
|
||||
upper, lower = _channel_bands(ohlcv_df, method, length=20, band_std=None)
|
||||
assert upper.ndim == 1
|
||||
assert lower.ndim == 1
|
||||
valid = upper.dropna().index.intersection(lower.dropna().index)
|
||||
assert (upper.loc[valid] >= lower.loc[valid]).all()
|
||||
|
||||
def test_bollinger_band_std_passed_through(self, ohlcv_df):
|
||||
u1, l1 = _channel_bands(ohlcv_df, "bollinger", length=20, band_std=1.0)
|
||||
u3, l3 = _channel_bands(ohlcv_df, "bollinger", length=20, band_std=3.0)
|
||||
valid = u1.dropna().index.intersection(u3.dropna().index)
|
||||
assert (u3.loc[valid] >= u1.loc[valid]).all()
|
||||
assert (l3.loc[valid] <= l1.loc[valid]).all()
|
||||
|
||||
|
||||
class TestQueryParamModel:
|
||||
def test_defaults(self, long_records):
|
||||
params = BreakoutsQueryParams(data=long_records)
|
||||
assert params.index == "date"
|
||||
assert params.method == "donchian"
|
||||
assert params.length == 20
|
||||
assert params.band_std is None
|
||||
|
||||
def test_rejects_invalid_method(self, long_records):
|
||||
with pytest.raises(Exception):
|
||||
BreakoutsQueryParams(data=long_records, method="keltner") # type: ignore[arg-type]
|
||||
|
||||
def test_band_std_must_be_positive(self, long_records):
|
||||
with pytest.raises(Exception):
|
||||
BreakoutsQueryParams(data=long_records, band_std=-1.0)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for openbb_technical.signals.crossovers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.crossovers import (
|
||||
CrossoverEvent,
|
||||
CrossoversQueryParams,
|
||||
crossovers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def monotone_records(ohlcv_df):
|
||||
"""Strictly increasing OHLCV — fast and slow MAs never cross."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sinusoidal_records():
|
||||
"""Sinusoidal close series that guarantees multiple bullish/bearish crossovers."""
|
||||
periods = 200
|
||||
t = np.arange(periods)
|
||||
close = 100 + 10 * np.sin(t / 8.0)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": close * 100,
|
||||
},
|
||||
index=pd.date_range("2020-01-01", periods=periods, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
class TestCrossoversBehaviour:
|
||||
def test_default_returns_obbject(self, sinusoidal_records):
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=5, slow_length=20
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
assert all(isinstance(r, CrossoverEvent) for r in result.results)
|
||||
|
||||
@pytest.mark.parametrize("mamode", ["sma", "ema", "wma", "hma", "zlma"])
|
||||
def test_each_mamode_produces_events(self, sinusoidal_records, mamode):
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=5, slow_length=20, mamode=mamode
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
assert all(r.direction in {"bullish", "bearish"} for r in result.results)
|
||||
|
||||
def test_sparse_output_no_crossovers(self, monotone_records):
|
||||
"""Strictly increasing series produces zero crossovers."""
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(data=monotone_records, fast_length=5, slow_length=20)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_distance_sign_and_magnitude(self, sinusoidal_records):
|
||||
"""`distance` equals fast - slow and matches direction polarity."""
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=5, slow_length=20, mamode="sma"
|
||||
)
|
||||
)
|
||||
assert result.results
|
||||
for ev in result.results:
|
||||
assert ev.distance == pytest.approx(ev.fast_value - ev.slow_value)
|
||||
if ev.direction == "bullish":
|
||||
assert ev.distance > 0
|
||||
else:
|
||||
assert ev.distance < 0
|
||||
|
||||
def test_alternating_directions(self, sinusoidal_records):
|
||||
"""Adjacent crossover events must alternate direction."""
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=5, slow_length=20, mamode="sma"
|
||||
)
|
||||
)
|
||||
directions = [e.direction for e in result.results]
|
||||
for prev, nxt in zip(directions, directions[1:]):
|
||||
assert prev != nxt
|
||||
|
||||
|
||||
class TestCrossoversValidator:
|
||||
def test_slow_must_exceed_fast(self, sinusoidal_records):
|
||||
with pytest.raises(
|
||||
ValueError, match="slow_length must be greater than fast_length"
|
||||
):
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=50, slow_length=20
|
||||
)
|
||||
|
||||
def test_slow_equal_to_fast_rejected(self, sinusoidal_records):
|
||||
with pytest.raises(
|
||||
ValueError, match="slow_length must be greater than fast_length"
|
||||
):
|
||||
CrossoversQueryParams(
|
||||
data=sinusoidal_records, fast_length=20, slow_length=20
|
||||
)
|
||||
|
||||
|
||||
class TestCrossoversQueryParamDefaults:
|
||||
def test_defaults(self, sinusoidal_records):
|
||||
params = CrossoversQueryParams(data=sinusoidal_records)
|
||||
assert params.target == "close"
|
||||
assert params.index == "date"
|
||||
assert params.fast_length == 20
|
||||
assert params.slow_length == 50
|
||||
assert params.mamode == "sma"
|
||||
|
||||
|
||||
class TestCrossoversEdgeCases:
|
||||
def test_empty_diff_yields_no_events(self):
|
||||
"""Insufficient warmup for ``hma`` leaves the diff empty after dropna."""
|
||||
periods = 20
|
||||
close = np.arange(1, periods + 1, dtype="float64")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": close,
|
||||
"high": close + 0.5,
|
||||
"low": close - 0.5,
|
||||
"close": close,
|
||||
"volume": close * 100,
|
||||
},
|
||||
index=pd.date_range("2020-01-01", periods=periods, freq="D", name="date"),
|
||||
)
|
||||
records = df_to_basemodel(df.reset_index())
|
||||
result = crossovers(
|
||||
CrossoversQueryParams(
|
||||
data=records, fast_length=5, slow_length=20, mamode="hma"
|
||||
)
|
||||
)
|
||||
assert result.results == []
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Tests for openbb_technical.signals.divergences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.divergences import (
|
||||
DivergenceEvent,
|
||||
DivergencesQueryParams,
|
||||
_classify_pair,
|
||||
_compute_indicator,
|
||||
_local_extrema,
|
||||
_strength,
|
||||
divergences,
|
||||
)
|
||||
|
||||
|
||||
def _records(prices: list[float], start: str = "2022-01-01") -> list:
|
||||
n = len(prices)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": prices,
|
||||
"high": [p + 0.1 for p in prices],
|
||||
"low": [p - 0.1 for p in prices],
|
||||
"close": prices,
|
||||
"volume": [1000.0] * n,
|
||||
},
|
||||
index=pd.date_range(start, periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regular_bullish_records():
|
||||
"""Price lower-low, indicator higher-low."""
|
||||
prices: list[float] = []
|
||||
prices.extend([100.0] * 20)
|
||||
prices.extend([float(v) for v in np.linspace(100, 80, 11)[1:]])
|
||||
prices.extend([float(v) for v in np.linspace(80, 95, 11)[1:]])
|
||||
prices.extend([95.0] * 5)
|
||||
prices.extend([float(v) for v in np.linspace(95, 75, 11)[1:]])
|
||||
prices.extend([float(v) for v in np.linspace(75, 85, 11)[1:]])
|
||||
while len(prices) < 100:
|
||||
prices.append(85.0)
|
||||
return _records(prices[:100])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regular_bearish_records():
|
||||
"""Price higher-high, indicator lower-high."""
|
||||
prices: list[float] = []
|
||||
prices.extend([100.0] * 20)
|
||||
prices.extend([float(v) for v in np.linspace(100, 120, 11)[1:]])
|
||||
prices.extend([float(v) for v in np.linspace(120, 105, 11)[1:]])
|
||||
prices.extend([105.0] * 5)
|
||||
prices.extend([float(v) for v in np.linspace(105, 125, 11)[1:]])
|
||||
prices.extend([float(v) for v in np.linspace(125, 115, 11)[1:]])
|
||||
while len(prices) < 100:
|
||||
prices.append(115.0)
|
||||
return _records(prices[:100])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hidden_bullish_records():
|
||||
"""Price higher-low, indicator lower-low."""
|
||||
prices: list[float] = [
|
||||
100.0,
|
||||
101.0,
|
||||
100.5,
|
||||
101.5,
|
||||
101.0,
|
||||
102.0,
|
||||
101.5,
|
||||
100.5,
|
||||
99.5,
|
||||
100.0,
|
||||
99.5,
|
||||
98.5,
|
||||
97.5,
|
||||
96.5,
|
||||
95.5,
|
||||
94.5,
|
||||
93.5,
|
||||
92.5,
|
||||
91.5,
|
||||
90.5,
|
||||
90.0,
|
||||
]
|
||||
prices.extend([float(v) for v in np.linspace(90, 105, 11)[1:]])
|
||||
prices.extend([105.0] * 10)
|
||||
prices.extend([105.0, 103.0, 100.0, 97.0, 95.0])
|
||||
prices.extend([96.0, 98.0, 100.0, 102.0, 104.0])
|
||||
while len(prices) < 100:
|
||||
prices.append(104.0)
|
||||
return _records(prices[:100])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hidden_bearish_records():
|
||||
"""Price lower-high, indicator higher-high."""
|
||||
prices: list[float] = [
|
||||
100.0,
|
||||
99.0,
|
||||
99.5,
|
||||
98.5,
|
||||
99.0,
|
||||
98.0,
|
||||
98.5,
|
||||
99.5,
|
||||
100.5,
|
||||
100.0,
|
||||
100.5,
|
||||
101.5,
|
||||
102.5,
|
||||
103.5,
|
||||
104.5,
|
||||
105.5,
|
||||
106.5,
|
||||
107.5,
|
||||
108.5,
|
||||
109.5,
|
||||
110.0,
|
||||
]
|
||||
prices.extend([float(v) for v in np.linspace(110, 95, 11)[1:]])
|
||||
prices.extend([95.0] * 10)
|
||||
prices.extend([95.0, 97.0, 100.0, 103.0, 105.0])
|
||||
prices.extend([104.0, 102.0, 100.0, 98.0, 96.0])
|
||||
while len(prices) < 100:
|
||||
prices.append(96.0)
|
||||
return _records(prices[:100])
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def random_walk_records():
|
||||
"""A 100-bar random walk — used to exercise every indicator branch."""
|
||||
np.random.seed(7)
|
||||
prices = (100 + np.cumsum(np.random.randn(100))).tolist()
|
||||
return _records(prices)
|
||||
|
||||
|
||||
class TestDivergencesEndpoint:
|
||||
def test_regular_bullish(self, regular_bullish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=regular_bullish_records,
|
||||
indicator="rsi",
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
kinds = {e.kind for e in result.results}
|
||||
assert "regular_bullish" in kinds
|
||||
events = [e for e in result.results if e.kind == "regular_bullish"]
|
||||
e = events[0]
|
||||
assert e.price_at_confirmation < e.price_at_prior
|
||||
assert e.indicator_at_confirmation > e.indicator_at_prior
|
||||
assert 0.0 <= e.strength <= 1.0
|
||||
assert isinstance(e, DivergenceEvent)
|
||||
|
||||
def test_regular_bearish(self, regular_bearish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=regular_bearish_records,
|
||||
indicator="rsi",
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
kinds = {e.kind for e in result.results}
|
||||
assert "regular_bearish" in kinds
|
||||
events = [e for e in result.results if e.kind == "regular_bearish"]
|
||||
e = events[0]
|
||||
assert e.price_at_confirmation > e.price_at_prior
|
||||
assert e.indicator_at_confirmation < e.indicator_at_prior
|
||||
|
||||
def test_hidden_bullish(self, hidden_bullish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=hidden_bullish_records,
|
||||
indicator="rsi",
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
kinds = {e.kind for e in result.results}
|
||||
assert "hidden_bullish" in kinds
|
||||
|
||||
def test_hidden_bearish(self, hidden_bearish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=hidden_bearish_records,
|
||||
indicator="rsi",
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
kinds = {e.kind for e in result.results}
|
||||
assert "hidden_bearish" in kinds
|
||||
|
||||
@pytest.mark.parametrize("indicator", ["rsi", "macd", "stoch", "cci"])
|
||||
def test_every_indicator_runs(self, random_walk_records, indicator):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=random_walk_records,
|
||||
indicator=indicator,
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
dates = [e.confirmation_date for e in result.results]
|
||||
assert dates == sorted(dates)
|
||||
|
||||
def test_short_lookback_returns_empty(self, regular_bullish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=regular_bullish_records,
|
||||
indicator="rsi",
|
||||
lookback=8,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_events_sorted_chronologically(self, regular_bullish_records):
|
||||
result = divergences(
|
||||
DivergencesQueryParams(
|
||||
data=regular_bullish_records,
|
||||
indicator="rsi",
|
||||
lookback=100,
|
||||
min_swing_distance=5,
|
||||
)
|
||||
)
|
||||
dates = [e.confirmation_date for e in result.results]
|
||||
assert dates == sorted(dates)
|
||||
|
||||
|
||||
class TestLocalExtrema:
|
||||
def test_finds_clear_peak_and_trough(self):
|
||||
values = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 9.0, 8.0, 7.0, 6.0, 5.0]
|
||||
highs, lows = _local_extrema(values, distance=3)
|
||||
assert highs == [5]
|
||||
assert lows == []
|
||||
|
||||
def test_plateau_at_extremum_skipped(self):
|
||||
values = [1.0, 2.0, 3.0, 3.0, 2.0, 1.0]
|
||||
highs, lows = _local_extrema(values, distance=1)
|
||||
assert highs == []
|
||||
|
||||
def test_min_in_middle(self):
|
||||
values = [10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]
|
||||
highs, lows = _local_extrema(values, distance=3)
|
||||
assert lows == [5]
|
||||
|
||||
|
||||
class TestClassifyPair:
|
||||
def test_regular_bearish(self):
|
||||
assert _classify_pair(100, 110, 80, 70, is_high=True) == "regular_bearish"
|
||||
|
||||
def test_hidden_bearish(self):
|
||||
assert _classify_pair(110, 100, 70, 80, is_high=True) == "hidden_bearish"
|
||||
|
||||
def test_high_no_divergence(self):
|
||||
assert _classify_pair(100, 110, 70, 80, is_high=True) is None
|
||||
|
||||
def test_regular_bullish(self):
|
||||
assert _classify_pair(100, 90, 20, 30, is_high=False) == "regular_bullish"
|
||||
|
||||
def test_hidden_bullish(self):
|
||||
assert _classify_pair(90, 100, 30, 20, is_high=False) == "hidden_bullish"
|
||||
|
||||
def test_low_no_divergence(self):
|
||||
assert _classify_pair(100, 90, 30, 20, is_high=False) is None
|
||||
|
||||
|
||||
class TestStrength:
|
||||
def test_zero_displacement(self):
|
||||
assert _strength(100.0, 100.0, 50.0, 50.0) == 0.0
|
||||
|
||||
def test_clamped_to_unit_interval(self):
|
||||
s = _strength(1e-12, 1.0, 1e-12, 1.0)
|
||||
assert 0.0 <= s <= 1.0
|
||||
|
||||
|
||||
class TestComputeIndicator:
|
||||
@pytest.mark.parametrize("indicator", ["rsi", "macd", "stoch", "cci"])
|
||||
def test_returns_aligned_series(self, random_walk_records, indicator):
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": [r.open for r in random_walk_records],
|
||||
"high": [r.high for r in random_walk_records],
|
||||
"low": [r.low for r in random_walk_records],
|
||||
"close": [r.close for r in random_walk_records],
|
||||
"volume": [r.volume for r in random_walk_records],
|
||||
},
|
||||
index=pd.to_datetime([r.date for r in random_walk_records]),
|
||||
)
|
||||
series = _compute_indicator(df, indicator, 14, "close")
|
||||
assert len(series) == len(df)
|
||||
|
||||
|
||||
class TestQueryParamModel:
|
||||
def test_defaults(self, regular_bullish_records):
|
||||
params = DivergencesQueryParams(data=regular_bullish_records, indicator="rsi")
|
||||
assert params.indicator_length == 14
|
||||
assert params.target == "close"
|
||||
assert params.lookback == 60
|
||||
assert params.min_swing_distance == 5
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tests for openbb_technical.signals.patterns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.patterns import (
|
||||
SUPPORTED_PATTERNS,
|
||||
CandlestickPatternsQueryParams,
|
||||
PatternEvent,
|
||||
candlestick_patterns,
|
||||
)
|
||||
|
||||
|
||||
def _bar(o: float, h: float, l: float, c: float) -> dict:
|
||||
return {"open": o, "high": h, "low": l, "close": c, "volume": 1000.0}
|
||||
|
||||
|
||||
def _frame(rows: list[dict]) -> list:
|
||||
df = pd.DataFrame(
|
||||
rows,
|
||||
index=pd.date_range("2022-01-01", periods=len(rows), freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def doji_records():
|
||||
"""A tiny-body bar with long shadows on both sides."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 105.0, 95.0, 100.05),
|
||||
_bar(100.0, 100.6, 99.4, 100.5),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hammer_records():
|
||||
"""Small body with a long lower shadow and tiny upper shadow."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 100.2, 90.0, 100.2),
|
||||
_bar(101.0, 101.5, 100.5, 101.2),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marubozu_records():
|
||||
"""Full-body up bar with negligible shadows."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 110.0, 100.0, 110.0),
|
||||
_bar(110.0, 110.0, 100.0, 100.0),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spinning_top_records():
|
||||
"""Small body centred between roughly equal shadows."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 105.0, 95.0, 100.5),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engulfing_records():
|
||||
"""Small red bar followed by a much larger green bar that engulfs it."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 100.2, 99.8, 99.9),
|
||||
_bar(99.5, 102.0, 99.4, 101.5),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neutral_records():
|
||||
"""Medium-body bars that don't match any of the five native patterns."""
|
||||
return _frame(
|
||||
[
|
||||
_bar(100.0, 100.25, 99.25, 100.5),
|
||||
_bar(100.5, 101.0, 99.75, 101.0),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestCandlestickEndpoint:
|
||||
def test_doji_detected(self, doji_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=doji_records, patterns=["doji"])
|
||||
)
|
||||
names = [e.pattern for e in result.results]
|
||||
assert "doji" in names
|
||||
doji_events = [e for e in result.results if e.pattern == "doji"]
|
||||
assert doji_events[0].direction == "neutral"
|
||||
assert 0.0 < doji_events[0].confidence <= 1.0
|
||||
|
||||
def test_hammer_detected(self, hammer_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=hammer_records, patterns=["hammer"])
|
||||
)
|
||||
hammers = [e for e in result.results if e.pattern == "hammer"]
|
||||
assert len(hammers) == 1
|
||||
assert hammers[0].direction == "bullish"
|
||||
|
||||
def test_marubozu_directions(self, marubozu_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=marubozu_records, patterns=["marubozu"])
|
||||
)
|
||||
marubozus = [e for e in result.results if e.pattern == "marubozu"]
|
||||
assert len(marubozus) == 2
|
||||
directions = {m.direction for m in marubozus}
|
||||
assert directions == {"bullish", "bearish"}
|
||||
|
||||
def test_spinning_top_detected(self, spinning_top_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(
|
||||
data=spinning_top_records, patterns=["spinning_top"]
|
||||
)
|
||||
)
|
||||
tops = [e for e in result.results if e.pattern == "spinning_top"]
|
||||
assert len(tops) == 1
|
||||
assert tops[0].direction == "neutral"
|
||||
|
||||
def test_engulfing_detected(self, engulfing_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(
|
||||
data=engulfing_records, patterns=["engulfing"]
|
||||
)
|
||||
)
|
||||
engulfings = [e for e in result.results if e.pattern == "engulfing"]
|
||||
assert len(engulfings) == 1
|
||||
assert engulfings[0].direction == "bullish"
|
||||
|
||||
def test_neutral_bars_produce_no_events(self, neutral_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=neutral_records)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_default_patterns_runs_all(self, doji_records):
|
||||
result = candlestick_patterns(CandlestickPatternsQueryParams(data=doji_records))
|
||||
assert any(e.pattern == "doji" for e in result.results)
|
||||
|
||||
def test_unsupported_pattern_silently_dropped(self, doji_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(
|
||||
data=doji_records, patterns=["three_white_soldiers"]
|
||||
)
|
||||
)
|
||||
assert result.results == []
|
||||
|
||||
def test_mixed_supported_and_unsupported(self, doji_records):
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(
|
||||
data=doji_records, patterns=["doji", "three_white_soldiers"]
|
||||
)
|
||||
)
|
||||
assert any(e.pattern == "doji" for e in result.results)
|
||||
|
||||
def test_all_results_are_pattern_events(self, doji_records):
|
||||
result = candlestick_patterns(CandlestickPatternsQueryParams(data=doji_records))
|
||||
assert all(isinstance(e, PatternEvent) for e in result.results)
|
||||
|
||||
|
||||
class TestDetectorRejection:
|
||||
def test_zero_range_bar_rejected(self):
|
||||
records = _frame([_bar(100.0, 100.0, 100.0, 100.0)])
|
||||
result = candlestick_patterns(CandlestickPatternsQueryParams(data=records))
|
||||
assert result.results == []
|
||||
|
||||
def test_doji_with_large_body_rejected(self):
|
||||
records = _frame([_bar(100.0, 105.0, 100.0, 105.0)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["doji"])
|
||||
)
|
||||
assert not any(e.pattern == "doji" for e in result.results)
|
||||
|
||||
def test_hammer_with_long_upper_shadow_rejected(self):
|
||||
records = _frame([_bar(100.0, 110.0, 99.5, 100.2)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["hammer"])
|
||||
)
|
||||
assert not any(e.pattern == "hammer" for e in result.results)
|
||||
|
||||
def test_hammer_short_lower_shadow_rejected(self):
|
||||
records = _frame([_bar(100.0, 100.5, 99.5, 100.2)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["hammer"])
|
||||
)
|
||||
assert not any(e.pattern == "hammer" for e in result.results)
|
||||
|
||||
def test_hammer_body_not_in_upper_third(self):
|
||||
records = _frame([_bar(4.0, 5.0, 1.0, 3.0)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["hammer"])
|
||||
)
|
||||
assert not any(e.pattern == "hammer" for e in result.results)
|
||||
|
||||
def test_marubozu_with_shadows_rejected(self):
|
||||
records = _frame([_bar(100.0, 110.0, 99.0, 109.0)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["marubozu"])
|
||||
)
|
||||
assert not any(e.pattern == "marubozu" for e in result.results)
|
||||
|
||||
def test_marubozu_doji_body_rejected(self):
|
||||
records = _frame([_bar(100.0, 105.0, 95.0, 100.05)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["marubozu"])
|
||||
)
|
||||
assert not any(e.pattern == "marubozu" for e in result.results)
|
||||
|
||||
def test_spinning_top_lopsided_rejected(self):
|
||||
records = _frame([_bar(100.0, 105.0, 99.9, 100.5)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["spinning_top"])
|
||||
)
|
||||
assert not any(e.pattern == "spinning_top" for e in result.results)
|
||||
|
||||
def test_spinning_top_too_large_body_rejected(self):
|
||||
records = _frame([_bar(100.0, 105.0, 100.0, 104.0)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["spinning_top"])
|
||||
)
|
||||
assert not any(e.pattern == "spinning_top" for e in result.results)
|
||||
|
||||
def test_spinning_top_tiny_body_rejected(self):
|
||||
records = _frame([_bar(100.0, 110.0, 90.0, 100.01)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["spinning_top"])
|
||||
)
|
||||
assert not any(e.pattern == "spinning_top" for e in result.results)
|
||||
|
||||
def test_spinning_top_zero_upper_shadow_rejected(self):
|
||||
records = _frame([_bar(100.0, 100.1, 99.0, 100.1)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["spinning_top"])
|
||||
)
|
||||
assert not any(e.pattern == "spinning_top" for e in result.results)
|
||||
|
||||
def test_spinning_top_zero_lower_shadow_rejected(self):
|
||||
records = _frame([_bar(100.1, 101.0, 100.0, 100.0)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["spinning_top"])
|
||||
)
|
||||
assert not any(e.pattern == "spinning_top" for e in result.results)
|
||||
|
||||
def test_engulfing_first_bar_has_no_prev(self):
|
||||
records = _frame([_bar(99.5, 102.0, 99.4, 101.5)])
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
assert not any(e.pattern == "engulfing" for e in result.results)
|
||||
|
||||
def test_engulfing_same_direction_rejected(self):
|
||||
records = _frame(
|
||||
[
|
||||
_bar(100.0, 100.5, 99.9, 100.3),
|
||||
_bar(99.5, 102.0, 99.4, 101.5),
|
||||
]
|
||||
)
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
assert not any(e.pattern == "engulfing" for e in result.results)
|
||||
|
||||
def test_engulfing_zero_body_rejected(self):
|
||||
records = _frame(
|
||||
[
|
||||
_bar(100.0, 100.5, 99.5, 100.0),
|
||||
_bar(99.5, 102.0, 99.4, 101.5),
|
||||
]
|
||||
)
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
assert not any(e.pattern == "engulfing" for e in result.results)
|
||||
|
||||
def test_engulfing_current_zero_body_rejected(self):
|
||||
records = _frame(
|
||||
[
|
||||
_bar(100.0, 100.5, 99.0, 99.5),
|
||||
_bar(99.7, 100.6, 99.4, 99.7),
|
||||
]
|
||||
)
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
assert not any(e.pattern == "engulfing" for e in result.results)
|
||||
|
||||
def test_engulfing_not_full_overlap_rejected(self):
|
||||
records = _frame(
|
||||
[
|
||||
_bar(100.0, 101.0, 99.0, 99.5),
|
||||
_bar(99.6, 100.5, 99.4, 100.4),
|
||||
]
|
||||
)
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
assert not any(e.pattern == "engulfing" for e in result.results)
|
||||
|
||||
def test_engulfing_bearish(self):
|
||||
records = _frame(
|
||||
[
|
||||
_bar(100.0, 101.0, 99.8, 100.8),
|
||||
_bar(101.0, 101.2, 98.5, 99.0),
|
||||
]
|
||||
)
|
||||
result = candlestick_patterns(
|
||||
CandlestickPatternsQueryParams(data=records, patterns=["engulfing"])
|
||||
)
|
||||
bears = [e for e in result.results if e.direction == "bearish"]
|
||||
assert len(bears) == 1
|
||||
|
||||
|
||||
class TestQueryParamModel:
|
||||
def test_defaults(self, doji_records):
|
||||
params = CandlestickPatternsQueryParams(data=doji_records)
|
||||
assert params.patterns is None
|
||||
assert params.index == "date"
|
||||
|
||||
def test_supported_patterns_constant(self):
|
||||
assert isinstance(SUPPORTED_PATTERNS, tuple)
|
||||
assert len(SUPPORTED_PATTERNS) == 5
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for openbb_technical.signals.regime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.regime import (
|
||||
RegimeData,
|
||||
RegimeQueryParams,
|
||||
_classify,
|
||||
regime,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def trending_records():
|
||||
"""A clean monotone uptrend — ADX should rise, choppiness should fall."""
|
||||
n = 120
|
||||
base = np.arange(1, n + 1, dtype="float64")
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": base,
|
||||
"high": base + 0.5,
|
||||
"low": base - 0.5,
|
||||
"close": base,
|
||||
"volume": base * 100,
|
||||
},
|
||||
index=pd.date_range("2022-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def choppy_records():
|
||||
"""Sideways sawtooth so choppiness sits near the upper rail."""
|
||||
n = 120
|
||||
sawtooth = np.tile([100.0, 101.0, 100.0, 99.0], n // 4 + 1)[:n]
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"open": sawtooth,
|
||||
"high": sawtooth + 0.2,
|
||||
"low": sawtooth - 0.2,
|
||||
"close": sawtooth,
|
||||
"volume": np.full(n, 1000.0),
|
||||
},
|
||||
index=pd.date_range("2022-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
return df_to_basemodel(df.reset_index())
|
||||
|
||||
|
||||
class TestRegimeEndpoint:
|
||||
def test_returns_one_row_per_bar(self, trending_records):
|
||||
result = regime(RegimeQueryParams(data=trending_records))
|
||||
assert len(result.results) == 120
|
||||
assert all(isinstance(r, RegimeData) for r in result.results)
|
||||
|
||||
def test_trending_data_reaches_trend_label(self, trending_records):
|
||||
result = regime(RegimeQueryParams(data=trending_records))
|
||||
labels = {r.regime for r in result.results}
|
||||
assert "strong_trend" in labels or "weak_trend" in labels
|
||||
|
||||
def test_first_bars_are_warm_up_transition(self, trending_records):
|
||||
result = regime(RegimeQueryParams(data=trending_records))
|
||||
assert result.results[0].regime == "transition"
|
||||
assert result.results[0].regime_changed is False
|
||||
assert result.results[0].adx is None
|
||||
assert result.results[0].choppiness is None
|
||||
|
||||
def test_choppy_data_hits_ranging(self, choppy_records):
|
||||
result = regime(RegimeQueryParams(data=choppy_records))
|
||||
labels = {r.regime for r in result.results}
|
||||
assert "ranging" in labels
|
||||
|
||||
def test_regime_changed_flag(self, trending_records):
|
||||
result = regime(RegimeQueryParams(data=trending_records))
|
||||
assert any(r.regime_changed for r in result.results)
|
||||
|
||||
def test_custom_thresholds_alter_labels(self, trending_records):
|
||||
tight = regime(
|
||||
RegimeQueryParams(data=trending_records, adx_trend_threshold=200.0)
|
||||
)
|
||||
assert "strong_trend" not in {r.regime for r in tight.results}
|
||||
|
||||
|
||||
class TestClassifyDirect:
|
||||
"""Drive ``_classify`` directly to guarantee every branch executes."""
|
||||
|
||||
def test_none_adx_short_circuits(self):
|
||||
assert _classify(None, 10.0, 25.0, 61.8) == "transition"
|
||||
|
||||
def test_none_choppiness_short_circuits(self):
|
||||
assert _classify(50.0, None, 25.0, 61.8) == "transition"
|
||||
|
||||
def test_strong_trend(self):
|
||||
assert _classify(40.0, 30.0, 25.0, 61.8) == "strong_trend"
|
||||
|
||||
def test_weak_trend(self):
|
||||
assert _classify(20.0, 70.0, 25.0, 61.8) == "weak_trend"
|
||||
|
||||
def test_weak_trend_high_chop_overrides_strong(self):
|
||||
assert _classify(30.0, 70.0, 25.0, 61.8) == "weak_trend"
|
||||
|
||||
def test_ranging(self):
|
||||
assert _classify(5.0, 70.0, 25.0, 61.8) == "ranging"
|
||||
|
||||
def test_transition_fallback(self):
|
||||
assert _classify(5.0, 30.0, 25.0, 61.8) == "transition"
|
||||
|
||||
|
||||
class TestQueryParamModel:
|
||||
def test_defaults(self, trending_records):
|
||||
params = RegimeQueryParams(data=trending_records)
|
||||
assert params.adx_length == 14
|
||||
assert params.choppiness_length == 14
|
||||
assert params.adx_trend_threshold == 25.0
|
||||
assert params.choppiness_range_threshold == 61.8
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for openbb_technical.signals.thresholds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.app.utils import df_to_basemodel
|
||||
|
||||
from openbb_technical.signals.thresholds import (
|
||||
_DEFAULT_THRESHOLDS,
|
||||
OscillatorSignal,
|
||||
OscillatorSignalsQueryParams,
|
||||
_compute_oscillator,
|
||||
oscillator_signals,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def long_records(ohlcv_df):
|
||||
"""Monotone OHLCV — used for happy-path defaults."""
|
||||
return df_to_basemodel(ohlcv_df.reset_index())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regime_change_df() -> pd.DataFrame:
|
||||
"""A constructed series that visits overbought, neutral, and oversold regimes."""
|
||||
n = 80
|
||||
prices = np.full(n, 100.0)
|
||||
prices[10:25] = np.linspace(100, 150, 15)
|
||||
prices[25:40] = 150.0
|
||||
prices[40:55] = np.linspace(150, 70, 15)
|
||||
prices[55:] = 70.0
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": prices,
|
||||
"high": prices + 1,
|
||||
"low": prices - 1,
|
||||
"close": prices,
|
||||
"volume": np.full(n, 1000.0),
|
||||
},
|
||||
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regime_records(regime_change_df) -> list:
|
||||
return df_to_basemodel(regime_change_df.reset_index())
|
||||
|
||||
|
||||
class TestOscillatorSignalsBranches:
|
||||
"""Cover every Literal branch of ``indicator``."""
|
||||
|
||||
@pytest.mark.parametrize("indicator", list(_DEFAULT_THRESHOLDS.keys()))
|
||||
def test_each_indicator_returns_dense_series(self, long_records, indicator):
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=long_records, indicator=indicator, length=14
|
||||
)
|
||||
)
|
||||
assert len(result.results) == len(long_records)
|
||||
assert all(isinstance(r, OscillatorSignal) for r in result.results)
|
||||
|
||||
@pytest.mark.parametrize("indicator", list(_DEFAULT_THRESHOLDS.keys()))
|
||||
def test_first_row_has_no_crossing_flags(self, long_records, indicator):
|
||||
"""The first bar has no prior — every flag must be False."""
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=long_records, indicator=indicator, length=14
|
||||
)
|
||||
)
|
||||
first = result.results[0]
|
||||
assert first.crossed_into_overbought is False
|
||||
assert first.crossed_into_oversold is False
|
||||
assert first.crossed_out_of_overbought is False
|
||||
assert first.crossed_out_of_oversold is False
|
||||
|
||||
|
||||
class TestThresholdDefaults:
|
||||
"""Confirm the documented per-indicator defaults are actually applied."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator,ob,os_",
|
||||
[
|
||||
("rsi", 70.0, 30.0),
|
||||
("mfi", 80.0, 20.0),
|
||||
("stoch", 80.0, 20.0),
|
||||
("williams_r", -20.0, -80.0),
|
||||
("cci", 100.0, -100.0),
|
||||
],
|
||||
)
|
||||
def test_default_table(self, indicator, ob, os_):
|
||||
assert _DEFAULT_THRESHOLDS[indicator] == (ob, os_)
|
||||
|
||||
def test_explicit_overrides_take_precedence(self, regime_records):
|
||||
"""If both thresholds are given, defaults must NOT be used."""
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records,
|
||||
indicator="rsi",
|
||||
length=14,
|
||||
overbought_threshold=-1e9,
|
||||
oversold_threshold=-2e9,
|
||||
)
|
||||
)
|
||||
non_warmup = [r for r in result.results if r.value != 0.0]
|
||||
assert all(r.regime == "overbought" for r in non_warmup)
|
||||
|
||||
def test_overbought_only_override(self, regime_records):
|
||||
"""Only setting ``overbought_threshold`` keeps the default oversold band."""
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records,
|
||||
indicator="rsi",
|
||||
length=14,
|
||||
overbought_threshold=200.0,
|
||||
)
|
||||
)
|
||||
regimes = {r.regime for r in result.results}
|
||||
assert "overbought" not in regimes
|
||||
assert "oversold" in regimes
|
||||
|
||||
def test_oversold_only_override(self, regime_records):
|
||||
"""Only setting ``oversold_threshold`` keeps the default overbought band."""
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records,
|
||||
indicator="rsi",
|
||||
length=14,
|
||||
oversold_threshold=-200.0,
|
||||
)
|
||||
)
|
||||
regimes = {r.regime for r in result.results}
|
||||
assert "oversold" not in regimes
|
||||
assert "overbought" in regimes
|
||||
|
||||
|
||||
class TestRegimeAndCrossings:
|
||||
def test_visits_all_three_regimes(self, regime_records):
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records, indicator="rsi", length=14
|
||||
)
|
||||
)
|
||||
regimes = {r.regime for r in result.results}
|
||||
assert regimes == {"overbought", "neutral", "oversold"}
|
||||
|
||||
def test_crossing_flag_count_matches_regime_changes(self, regime_records):
|
||||
"""The crossings_into_X count == number of regime->X transitions."""
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records, indicator="rsi", length=14
|
||||
)
|
||||
)
|
||||
labels = [r.regime for r in result.results]
|
||||
into_ob_manual = sum(
|
||||
1
|
||||
for i in range(1, len(labels))
|
||||
if labels[i] == "overbought" and labels[i - 1] != "overbought"
|
||||
)
|
||||
into_os_manual = sum(
|
||||
1
|
||||
for i in range(1, len(labels))
|
||||
if labels[i] == "oversold" and labels[i - 1] != "oversold"
|
||||
)
|
||||
into_ob_flag = sum(r.crossed_into_overbought for r in result.results)
|
||||
into_os_flag = sum(r.crossed_into_oversold for r in result.results)
|
||||
assert into_ob_flag == into_ob_manual
|
||||
assert into_os_flag == into_os_manual
|
||||
|
||||
def test_crossing_out_matches_regime_exits(self, regime_records):
|
||||
result = oscillator_signals(
|
||||
OscillatorSignalsQueryParams(
|
||||
data=regime_records, indicator="rsi", length=14
|
||||
)
|
||||
)
|
||||
labels = [r.regime for r in result.results]
|
||||
out_ob_manual = sum(
|
||||
1
|
||||
for i in range(1, len(labels))
|
||||
if labels[i - 1] == "overbought" and labels[i] != "overbought"
|
||||
)
|
||||
out_os_manual = sum(
|
||||
1
|
||||
for i in range(1, len(labels))
|
||||
if labels[i - 1] == "oversold" and labels[i] != "oversold"
|
||||
)
|
||||
out_ob_flag = sum(r.crossed_out_of_overbought for r in result.results)
|
||||
out_os_flag = sum(r.crossed_out_of_oversold for r in result.results)
|
||||
assert out_ob_flag == out_ob_manual
|
||||
assert out_os_flag == out_os_manual
|
||||
|
||||
|
||||
class TestComputeOscillatorDispatcher:
|
||||
"""Direct unit-coverage on the dispatch helper."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator",
|
||||
["rsi", "mfi", "stoch", "williams_r", "cci"],
|
||||
)
|
||||
def test_dispatch(self, ohlcv_df, indicator):
|
||||
series = _compute_oscillator(ohlcv_df, indicator, length=14, target="close")
|
||||
assert series.ndim == 1
|
||||
assert series.dropna().shape[0] > 0
|
||||
|
||||
|
||||
class TestQueryParamModel:
|
||||
def test_defaults(self, long_records):
|
||||
params = OscillatorSignalsQueryParams(data=long_records, indicator="rsi")
|
||||
assert params.target == "close"
|
||||
assert params.index == "date"
|
||||
assert params.length == 14
|
||||
assert params.overbought_threshold is None
|
||||
assert params.oversold_threshold is None
|
||||
|
||||
def test_indicator_required(self, long_records):
|
||||
with pytest.raises(Exception):
|
||||
OscillatorSignalsQueryParams(data=long_records) # type: ignore[call-arg]
|
||||
|
||||
def test_rejects_invalid_indicator(self, long_records):
|
||||
with pytest.raises(Exception):
|
||||
OscillatorSignalsQueryParams(data=long_records, indicator="macd") # type: ignore[arg-type]
|
||||
190
openbb_platform/extensions/technical/tests/test_helpers.py
Normal file
190
openbb_platform/extensions/technical/tests/test_helpers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for openbb_technical.helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from openbb_technical.helpers import (
|
||||
calculate_cones,
|
||||
calculate_fib_levels,
|
||||
clenow_momentum,
|
||||
garman_klass,
|
||||
hodges_tompkins,
|
||||
parkinson,
|
||||
rogers_satchell,
|
||||
standard_deviation,
|
||||
validate_data,
|
||||
yang_zhang,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateData:
|
||||
def test_accepts_int_length(self, ohlcv_df):
|
||||
validate_data(ohlcv_df["close"].tolist(), 20)
|
||||
|
||||
def test_accepts_list_of_lengths(self, ohlcv_df):
|
||||
validate_data(ohlcv_df["close"].tolist(), [10, 20, 30])
|
||||
|
||||
def test_raises_when_too_short(self):
|
||||
with pytest.raises(ValueError, match="less than required"):
|
||||
validate_data([1.0, 2.0], 5)
|
||||
|
||||
def test_raises_when_any_length_exceeds(self):
|
||||
with pytest.raises(ValueError, match="less than required"):
|
||||
validate_data([1.0, 2.0, 3.0], [2, 5])
|
||||
|
||||
|
||||
class _VolatilityShared:
|
||||
"""Reusable branch sweep for the six volatility helpers."""
|
||||
|
||||
func: staticmethod = None # set in subclasses
|
||||
|
||||
def test_default_returns_non_empty(self, ohlcv_df):
|
||||
assert not self.__class__.func(ohlcv_df).empty
|
||||
|
||||
def test_is_crypto_picks_365_periods(self, ohlcv_df):
|
||||
assert not self.__class__.func(ohlcv_df, is_crypto=True).empty
|
||||
|
||||
def test_trading_periods_override_warns_when_crypto(self, ohlcv_df):
|
||||
with pytest.warns(UserWarning, match="is_crypto is overridden"):
|
||||
self.__class__.func(ohlcv_df, trading_periods=252, is_crypto=True)
|
||||
|
||||
def test_clean_false_keeps_leading_nans(self, ohlcv_df):
|
||||
result = self.__class__.func(ohlcv_df, clean=False)
|
||||
assert result.isna().any()
|
||||
|
||||
def test_window_too_small_warns_and_defaults(self, ohlcv_df):
|
||||
with pytest.warns(UserWarning, match="Window must be at least"):
|
||||
self.__class__.func(ohlcv_df, window=0)
|
||||
|
||||
|
||||
class TestParkinson(_VolatilityShared):
|
||||
func = staticmethod(parkinson)
|
||||
|
||||
|
||||
class TestStandardDeviation(_VolatilityShared):
|
||||
func = staticmethod(standard_deviation)
|
||||
|
||||
|
||||
class TestGarmanKlass(_VolatilityShared):
|
||||
func = staticmethod(garman_klass)
|
||||
|
||||
|
||||
class TestHodgesTompkins(_VolatilityShared):
|
||||
func = staticmethod(hodges_tompkins)
|
||||
|
||||
def test_window_too_small_warns_and_defaults(self, ohlcv_df):
|
||||
with pytest.warns(UserWarning, match="Window must be at least 2"):
|
||||
self.__class__.func(ohlcv_df, window=1)
|
||||
|
||||
|
||||
class TestRogersSatchell(_VolatilityShared):
|
||||
func = staticmethod(rogers_satchell)
|
||||
|
||||
|
||||
class TestYangZhang(_VolatilityShared):
|
||||
func = staticmethod(yang_zhang)
|
||||
|
||||
def test_window_too_small_warns_and_defaults(self, ohlcv_df):
|
||||
with pytest.warns(UserWarning, match="Window must be at least 2"):
|
||||
self.__class__.func(ohlcv_df, window=1)
|
||||
|
||||
|
||||
class TestClenowMomentum:
|
||||
def test_returns_r2_coef_predictions(self, ohlcv_df):
|
||||
r2, coef, preds = clenow_momentum(ohlcv_df["close"])
|
||||
assert isinstance(r2, float)
|
||||
assert isinstance(coef, float)
|
||||
assert len(preds) == 90
|
||||
|
||||
def test_raises_when_window_exceeds_data(self):
|
||||
with pytest.raises(ValueError, match="at least last"):
|
||||
clenow_momentum(pd.Series([1.0, 2.0]), window=90)
|
||||
|
||||
|
||||
class TestCalculateCones:
|
||||
def test_default_model_std(self, ohlcv_df):
|
||||
result = calculate_cones(
|
||||
ohlcv_df, lower_q=0.1, upper_q=0.9, is_crypto=False, model="std"
|
||||
)
|
||||
assert not result.empty
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"std",
|
||||
"parkinson",
|
||||
"garman_klass",
|
||||
"hodges_tompkins",
|
||||
"rogers_satchell",
|
||||
"yang_zhang",
|
||||
],
|
||||
)
|
||||
def test_every_model(self, ohlcv_df, model):
|
||||
result = calculate_cones(
|
||||
ohlcv_df, lower_q=0.2, upper_q=0.8, is_crypto=False, model=model
|
||||
)
|
||||
assert not result.empty
|
||||
|
||||
def test_is_crypto_branch(self, ohlcv_df):
|
||||
result = calculate_cones(
|
||||
ohlcv_df, lower_q=0.1, upper_q=0.9, is_crypto=True, model="std"
|
||||
)
|
||||
assert not result.empty
|
||||
|
||||
def test_swaps_quantiles_when_lower_above_upper(self, ohlcv_df):
|
||||
"""When lower_q > upper_q the function swaps them silently."""
|
||||
normal = calculate_cones(
|
||||
ohlcv_df, lower_q=0.1, upper_q=0.9, is_crypto=False, model="std"
|
||||
)
|
||||
swapped = calculate_cones(
|
||||
ohlcv_df, lower_q=0.9, upper_q=0.1, is_crypto=False, model="std"
|
||||
)
|
||||
assert list(normal.columns) == list(swapped.columns)
|
||||
|
||||
def test_raises_on_quantile_out_of_range(self, ohlcv_df):
|
||||
with pytest.raises(ValueError, match="between 0 and 1"):
|
||||
calculate_cones(
|
||||
ohlcv_df, lower_q=1.5, upper_q=2.0, is_crypto=False, model="std"
|
||||
)
|
||||
|
||||
|
||||
class TestCalculateFibLevels:
|
||||
def test_default_branch(self, ohlcv_df):
|
||||
result = calculate_fib_levels(ohlcv_df, "close")
|
||||
assert result is not None
|
||||
assert len(result) >= 6
|
||||
|
||||
def test_raises_on_missing_close_col(self, ohlcv_df):
|
||||
with pytest.raises(ValueError, match="not in data"):
|
||||
calculate_fib_levels(ohlcv_df, "missing_col")
|
||||
|
||||
def test_with_explicit_start_end_in_index(self, ohlcv_df):
|
||||
start = ohlcv_df.index[10]
|
||||
end = ohlcv_df.index[60]
|
||||
result = calculate_fib_levels(ohlcv_df, "close", start_date=start, end_date=end)
|
||||
assert result is not None
|
||||
|
||||
def test_with_explicit_start_end_not_in_index_warns(self, ohlcv_df):
|
||||
"""When user supplies dates outside the index, nearest neighbours are used and a warning is emitted."""
|
||||
start = ohlcv_df.index[10] + pd.Timedelta(hours=12)
|
||||
end = ohlcv_df.index[60] + pd.Timedelta(hours=12)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
calculate_fib_levels(ohlcv_df, "close", start_date=start, end_date=end)
|
||||
assert any("not in data" in str(w.message) for w in caught)
|
||||
|
||||
def test_swaps_when_min_date_after_max(self, ohlcv_df):
|
||||
"""When start_date is after end_date the function swaps the pair."""
|
||||
df = ohlcv_df.copy()
|
||||
df["close"] = np.concatenate(
|
||||
[np.arange(50, 0, -1, dtype=float), np.arange(1, 50, dtype=float)]
|
||||
)
|
||||
start = df.index[5]
|
||||
end = df.index[80]
|
||||
result = calculate_fib_levels(df, "close", start_date=start, end_date=end)
|
||||
assert result is not None
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Tests for openbb_technical.relative_rotation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
|
||||
from openbb_technical.relative_rotation import (
|
||||
RelativeRotation,
|
||||
RelativeRotationData,
|
||||
RelativeRotationFetcher,
|
||||
RelativeRotationQueryParams,
|
||||
_get_type_name,
|
||||
absolute_maximum_scale,
|
||||
calculate_momentum,
|
||||
calculate_relative_strength_ratio,
|
||||
get_momentum,
|
||||
min_max_scaling,
|
||||
normalize,
|
||||
process_data,
|
||||
standard_deviation,
|
||||
z_score_standardization,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multi_symbol_records() -> list[dict]:
|
||||
"""Long multi-symbol price history (3 symbols, 600 daily rows).
|
||||
|
||||
600 rows is enough to exceed RelativeRotation's two-year guard for
|
||||
volatility studies (>504 rows) and one-year guard for price/volume
|
||||
studies (>252 rows). Each symbol follows a smooth exponential ramp
|
||||
with a small offset so normalisation produces non-NaN output.
|
||||
"""
|
||||
dates = pd.date_range("2020-01-01", periods=600, freq="D")
|
||||
records: list[dict] = []
|
||||
for offset, symbol in enumerate(["AAPL", "MSFT", "SPY"]):
|
||||
for i, dt in enumerate(dates):
|
||||
records.append(
|
||||
{
|
||||
"date": dt.strftime("%Y-%m-%d"),
|
||||
"symbol": symbol,
|
||||
"close": float(100 + offset * 10 + i * (1 + offset * 0.1)),
|
||||
"volume": float(
|
||||
1_000_000 + offset * 50_000 + i * (10 + offset * 3)
|
||||
),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multi_symbol_df(multi_symbol_records: list[dict]) -> pd.DataFrame:
|
||||
return pd.DataFrame(multi_symbol_records)
|
||||
|
||||
|
||||
class TestScalers:
|
||||
def test_absolute_maximum_scale(self):
|
||||
s = pd.Series([2.0, -4.0, 1.0])
|
||||
result = absolute_maximum_scale(s)
|
||||
assert result.max() == 0.5
|
||||
assert result.min() == -1.0
|
||||
|
||||
def test_min_max_scaling(self):
|
||||
s = pd.Series([1.0, 3.0, 5.0])
|
||||
result = min_max_scaling(s)
|
||||
assert result.min() == 0.0
|
||||
assert result.max() == 1.0
|
||||
|
||||
def test_z_score_standardization(self):
|
||||
s = pd.Series([1.0, 2.0, 3.0])
|
||||
result = z_score_standardization(s)
|
||||
assert pytest.approx(result.mean(), abs=1e-9) == 0.0
|
||||
|
||||
|
||||
class TestNormalize:
|
||||
@pytest.mark.parametrize("method", ["z", "m", "a"])
|
||||
def test_each_method(self, method):
|
||||
df = pd.DataFrame({"a": [1.0, 2.0, 3.0, 4.0], "b": [10.0, 20.0, 30.0, 40.0]})
|
||||
out = normalize(df, method=method)
|
||||
assert out.shape == df.shape
|
||||
|
||||
|
||||
class TestStandardDeviation:
|
||||
def test_default_window(self):
|
||||
df = pd.DataFrame(
|
||||
{"close": np.linspace(100, 200, 60), "vol": np.linspace(50, 150, 60)},
|
||||
index=pd.date_range("2020-01-01", periods=60, freq="D"),
|
||||
)
|
||||
result = standard_deviation(df, window=21, trading_periods=252)
|
||||
assert not result.empty
|
||||
assert list(result.columns) == ["close", "vol"]
|
||||
|
||||
def test_window_below_two_defaults_to_21(self):
|
||||
df = pd.DataFrame(
|
||||
{"close": np.linspace(100, 200, 60)},
|
||||
index=pd.date_range("2020-01-01", periods=60, freq="D"),
|
||||
)
|
||||
out_default = standard_deviation(df, window=21, trading_periods=252)
|
||||
out_clamped = standard_deviation(df, window=1, trading_periods=252)
|
||||
assert out_clamped.shape == out_default.shape
|
||||
|
||||
|
||||
class TestCalculateMomentum:
|
||||
def test_returns_series(self):
|
||||
s = pd.Series(np.linspace(100, 200, 300))
|
||||
result = calculate_momentum(s, long_period=252, short_period=21)
|
||||
assert isinstance(result, pd.Series)
|
||||
|
||||
|
||||
class TestGetMomentum:
|
||||
def test_returns_dataframe_per_column(self):
|
||||
df = pd.DataFrame(
|
||||
{"A": np.linspace(100, 200, 300), "B": np.linspace(50, 100, 300)}
|
||||
)
|
||||
result = get_momentum(df, long_period=252, short_period=21)
|
||||
assert list(result.columns) == ["A", "B"]
|
||||
|
||||
|
||||
class TestCalculateRelativeStrengthRatio:
|
||||
def test_includes_benchmark_column(self):
|
||||
idx = pd.date_range("2020-01-01", periods=10, freq="D")
|
||||
symbols = pd.DataFrame(
|
||||
{"A": np.arange(10, 20.0), "B": np.arange(15, 25.0)}, index=idx
|
||||
)
|
||||
benchmark = pd.DataFrame({"SPY": np.arange(20, 30.0)}, index=idx)
|
||||
result = calculate_relative_strength_ratio(symbols, benchmark)
|
||||
assert "SPY" in result.columns
|
||||
|
||||
|
||||
class TestProcessData:
|
||||
def test_returns_two_normalised_frames(self):
|
||||
idx = pd.date_range("2020-01-01", periods=300, freq="D")
|
||||
symbols = pd.DataFrame(
|
||||
{
|
||||
"AAPL": np.linspace(100, 200, 300),
|
||||
"MSFT": np.linspace(120, 220, 300),
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
benchmark = pd.DataFrame({"SPY": np.linspace(110, 210, 300)}, index=idx)
|
||||
ratios, momentum = process_data(symbols, benchmark)
|
||||
assert not ratios.empty
|
||||
assert not momentum.empty
|
||||
|
||||
|
||||
class TestRelativeRotation:
|
||||
def test_constructs_from_records_price_study(self, multi_symbol_records):
|
||||
rr = RelativeRotation(data=multi_symbol_records, benchmark="SPY")
|
||||
assert "AAPL" in rr.symbols
|
||||
assert "MSFT" in rr.symbols
|
||||
assert rr.benchmark == "SPY"
|
||||
|
||||
def test_volume_study_pivots_on_volume(self, multi_symbol_records):
|
||||
rr = RelativeRotation(
|
||||
data=multi_symbol_records, benchmark="SPY", study="volume"
|
||||
)
|
||||
assert rr.study == "volume"
|
||||
|
||||
def test_volatility_study_requires_two_years(self, multi_symbol_records):
|
||||
rr = RelativeRotation(
|
||||
data=multi_symbol_records, benchmark="SPY", study="volatility"
|
||||
)
|
||||
assert rr.study == "volatility"
|
||||
|
||||
def test_raises_when_benchmark_missing(self, multi_symbol_records):
|
||||
with pytest.raises(RuntimeError, match="benchmark"):
|
||||
RelativeRotation(data=multi_symbol_records, benchmark="MISSING")
|
||||
|
||||
def test_raises_when_data_too_short_for_price(self):
|
||||
short = []
|
||||
dates = pd.date_range("2024-01-01", periods=10, freq="D")
|
||||
for symbol in ["AAPL", "SPY"]:
|
||||
for i, dt in enumerate(dates):
|
||||
short.append(
|
||||
{
|
||||
"date": dt.strftime("%Y-%m-%d"),
|
||||
"symbol": symbol,
|
||||
"close": float(100 + i),
|
||||
"volume": 1.0,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="more than one year"):
|
||||
RelativeRotation(data=short, benchmark="SPY")
|
||||
|
||||
def test_raises_when_data_too_short_for_volatility(self, multi_symbol_records):
|
||||
truncated = [r for r in multi_symbol_records if r["date"] < "2021-01-01"]
|
||||
with pytest.raises(ValueError, match="more than two years"):
|
||||
RelativeRotation(data=truncated, benchmark="SPY", study="volatility")
|
||||
|
||||
def test_empty_data_raises(self):
|
||||
with pytest.raises(ValueError, match="must be a list"):
|
||||
RelativeRotation(data=[], benchmark="SPY")
|
||||
|
||||
def test_accepts_obbject_input(self, multi_symbol_records):
|
||||
"""An OBBject wrapping records is unwrapped via ``data.results``."""
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
|
||||
wrapped = OBBject(results=multi_symbol_records)
|
||||
rr = RelativeRotation(data=wrapped, benchmark="SPY")
|
||||
assert "AAPL" in rr.symbols
|
||||
|
||||
|
||||
class TestRelativeRotationQueryParams:
|
||||
def test_benchmark_uppercased(self, multi_symbol_records):
|
||||
params = RelativeRotationQueryParams(data=multi_symbol_records, benchmark="spy")
|
||||
assert params.benchmark == "SPY"
|
||||
|
||||
def test_data_validator_passes_list(self, multi_symbol_records):
|
||||
params = RelativeRotationQueryParams(data=multi_symbol_records, benchmark="SPY")
|
||||
assert len(params.data) > 0
|
||||
|
||||
def test_data_validator_accepts_data_object(self):
|
||||
single = Data(date="2024-01-01", symbol="AAPL", close=100.0)
|
||||
params = RelativeRotationQueryParams(data=[single], benchmark="SPY")
|
||||
assert isinstance(params.data, list)
|
||||
|
||||
def test_data_validator_accepts_dataframe(self, multi_symbol_df):
|
||||
params = RelativeRotationQueryParams(
|
||||
data=multi_symbol_df.set_index("date"), benchmark="SPY"
|
||||
)
|
||||
assert len(params.data) > 0
|
||||
|
||||
def test_doc_string_built_from_fields(self, multi_symbol_records):
|
||||
params = RelativeRotationQueryParams(data=multi_symbol_records, benchmark="SPY")
|
||||
assert "Parameters" in params.__doc__
|
||||
assert "benchmark" in params.__doc__
|
||||
|
||||
def test_validator_unwraps_obbject(self, multi_symbol_records):
|
||||
"""The data validator returns ``v.results`` for OBBject input."""
|
||||
from openbb_core.app.model.obbject import OBBject
|
||||
|
||||
wrapped = OBBject(results=multi_symbol_records)
|
||||
params = RelativeRotationQueryParams(data=wrapped, benchmark="SPY")
|
||||
assert len(params.data) > 0
|
||||
|
||||
def test_validator_passes_data_through(self):
|
||||
"""A bare Data instance passes through validation."""
|
||||
single = Data(date="2024-01-01", symbol="AAPL", close=100.0)
|
||||
result = RelativeRotationQueryParams.convert_data(single)
|
||||
assert isinstance(result, Data)
|
||||
|
||||
def test_validator_passthrough_for_unknown_type(self):
|
||||
"""An unknown type returns unchanged from the validator."""
|
||||
sentinel = object()
|
||||
assert RelativeRotationQueryParams.convert_data(sentinel) is sentinel
|
||||
|
||||
|
||||
class TestRelativeRotationData:
|
||||
def test_doc_string_built_from_fields(self):
|
||||
instance = RelativeRotationData(
|
||||
symbols=["AAPL"],
|
||||
benchmark="SPY",
|
||||
study="price",
|
||||
long_period=252,
|
||||
short_period=21,
|
||||
window=21,
|
||||
trading_periods=252,
|
||||
start_date="2020-01-01",
|
||||
end_date="2020-12-31",
|
||||
symbols_data=[],
|
||||
benchmark_data=[],
|
||||
rs_ratios=[],
|
||||
rs_momentum=[],
|
||||
)
|
||||
assert "Attributes" in instance.__doc__
|
||||
|
||||
|
||||
class TestRelativeRotationFetcher:
|
||||
def test_extract_then_transform(self, multi_symbol_records):
|
||||
params = RelativeRotationQueryParams(data=multi_symbol_records, benchmark="SPY")
|
||||
raw = RelativeRotationFetcher.extract_data(params, None)
|
||||
assert "rs_ratios" in raw
|
||||
assert "rs_momentum" in raw
|
||||
result = RelativeRotationFetcher.transform_data(params, raw)
|
||||
assert isinstance(result, RelativeRotationData)
|
||||
assert result.benchmark == "SPY"
|
||||
|
||||
def test_transform_query_round_trip(self, multi_symbol_records):
|
||||
"""transform_query accepts a dict and returns a typed params model."""
|
||||
as_dict = {"data": multi_symbol_records, "benchmark": "SPY"}
|
||||
params = RelativeRotationFetcher.transform_query(as_dict)
|
||||
assert isinstance(params, RelativeRotationQueryParams)
|
||||
assert params.benchmark == "SPY"
|
||||
|
||||
|
||||
class TestGetTypeName:
|
||||
def test_string_passthrough(self):
|
||||
assert _get_type_name("MyClass") == "MyClass"
|
||||
|
||||
def test_named_class(self):
|
||||
assert _get_type_name(int) == "int"
|
||||
|
||||
def test_generic_with_origin_name(self):
|
||||
assert "list" in _get_type_name(list[int])
|
||||
|
||||
def test_underscore_name_variant(self):
|
||||
result = _get_type_name(int | str)
|
||||
assert "int" in result and "str" in result
|
||||
|
||||
def test_no_name_attr_falls_back_to_str(self):
|
||||
"""Objects without ``__name__`` / ``_name`` fall through to ``str(t)``."""
|
||||
|
||||
class _Bare:
|
||||
"""An instance without ``__name__`` / ``_name`` attrs."""
|
||||
|
||||
instance = _Bare()
|
||||
result = _get_type_name(instance)
|
||||
assert isinstance(result, str)
|
||||
assert "_Bare" in result
|
||||
43
openbb_platform/extensions/technical/tests/test_router.py
Normal file
43
openbb_platform/extensions/technical/tests/test_router.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Tests for the top-level openbb_technical.router aggregator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openbb_technical.router import router
|
||||
|
||||
|
||||
class TestTopLevelRouter:
|
||||
def test_aggregator_exposes_indicator_routes(self):
|
||||
paths = {r.path for r in router.api_router.routes}
|
||||
assert "/sma" in paths
|
||||
assert "/rsi" in paths
|
||||
assert "/macd" in paths
|
||||
assert "/atr" in paths
|
||||
assert "/realized_volatility" in paths
|
||||
assert "/aroon" in paths
|
||||
assert "/obv" in paths
|
||||
assert "/fib" in paths
|
||||
assert "/relative_rotation" in paths
|
||||
|
||||
def test_aggregator_exposes_signal_routes(self):
|
||||
paths = {r.path for r in router.api_router.routes}
|
||||
assert "/signals/crossovers" in paths
|
||||
assert "/signals/oscillator_signals" in paths
|
||||
assert "/signals/divergences" in paths
|
||||
assert "/signals/breakouts" in paths
|
||||
assert "/signals/candlestick_patterns" in paths
|
||||
assert "/signals/regime" in paths
|
||||
|
||||
def test_aggregator_exposes_multi_routes(self):
|
||||
paths = {r.path for r in router.api_router.routes}
|
||||
assert "/multi" in paths
|
||||
assert "/screen" in paths
|
||||
assert "/correlation" in paths
|
||||
assert "/correlation_matrix" in paths
|
||||
assert "/indicators" in paths
|
||||
|
||||
def test_no_duplicate_paths(self):
|
||||
paths = [r.path for r in router.api_router.routes]
|
||||
assert len(paths) == len(set(paths)), (
|
||||
f"Duplicate route registered. Counts: "
|
||||
f"{ {p: paths.count(p) for p in set(paths) if paths.count(p) > 1} }"
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Test the technical helpers module."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from extensions.technical.openbb_technical.helpers import (
|
||||
calculate_cones,
|
||||
calculate_fib_levels,
|
||||
clenow_momentum,
|
||||
garman_klass,
|
||||
hodges_tompkins,
|
||||
parkinson,
|
||||
rogers_satchell,
|
||||
validate_data,
|
||||
yang_zhang,
|
||||
)
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mock_data(top_range: int = 100):
|
||||
"""Mock data for testing."""
|
||||
_open = pd.Series(np.arange(1, top_range))
|
||||
_high = pd.Series(np.arange(1, top_range))
|
||||
_low = pd.Series(np.arange(1, top_range))
|
||||
_close = pd.Series(np.arange(1, top_range))
|
||||
_volume = pd.Series(np.arange(1, top_range))
|
||||
_date = pd.Series(pd.date_range("2021-01-01", periods=top_range - 1, freq="D"))
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"open": _open,
|
||||
"high": _high,
|
||||
"low": _low,
|
||||
"close": _close,
|
||||
"volume": _volume,
|
||||
"date": _date,
|
||||
}
|
||||
).set_index("date")
|
||||
|
||||
|
||||
def test_parkinson_with_mock_data(mock_data):
|
||||
"""Test parkinson with valid input."""
|
||||
result = parkinson(mock_data)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_garman_klass_with_mock_data(mock_data):
|
||||
"""Test garman_klass with valid input."""
|
||||
result = garman_klass(mock_data)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_hodges_tompkins_with_mock_data(mock_data):
|
||||
"""Test hodges_tompkins with valid input."""
|
||||
result = hodges_tompkins(mock_data)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_rogers_satchell_with_mock_data(mock_data):
|
||||
"""Test rogers_satchell with valid input."""
|
||||
result = rogers_satchell(mock_data)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_yang_zhang_with_mock_data(mock_data):
|
||||
"""Test yang_zhang with valid input."""
|
||||
result = yang_zhang(mock_data)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_clenow_momentum_with_mock_data(mock_data):
|
||||
"""Test clenow_momentum with valid input."""
|
||||
result = clenow_momentum(mock_data["close"])
|
||||
assert result
|
||||
|
||||
|
||||
def test_calculate_cones_with_mock_data(mock_data):
|
||||
"""Test calculate_cones with valid input."""
|
||||
result = calculate_cones(
|
||||
mock_data,
|
||||
lower_q=0.1,
|
||||
upper_q=0.9,
|
||||
is_crypto=False,
|
||||
model="std",
|
||||
)
|
||||
assert not result.empty
|
||||
|
||||
|
||||
def test_calculate_fib_levels_with_mock_data(mock_data):
|
||||
"""Test calculate_fib_levels with valid input."""
|
||||
result = calculate_fib_levels(mock_data, "close")
|
||||
assert result
|
||||
|
||||
|
||||
def test_validate_data_with_mock_data(mock_data):
|
||||
"""Test validate_data with valid input."""
|
||||
try:
|
||||
validate_data(mock_data["close"].tolist(), 20)
|
||||
except ValueError:
|
||||
pytest.fail("validate_data raised ValueError unexpectedly!")
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for openbb_technical.technical_views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_openbb_charting(monkeypatch):
|
||||
"""Stub openbb_charting module tree so technical_views imports succeed."""
|
||||
mod_names = [
|
||||
"openbb_charting",
|
||||
"openbb_charting.charts",
|
||||
"openbb_charting.charts.relative_rotation",
|
||||
"openbb_charting.core",
|
||||
"openbb_charting.core.chart_style",
|
||||
"openbb_charting.core.openbb_figure",
|
||||
"openbb_charting.core.plotly_ta",
|
||||
"openbb_charting.core.plotly_ta.ta_class",
|
||||
"openbb_charting.core.to_chart",
|
||||
"openbb_charting.styles",
|
||||
"openbb_charting.styles.colors",
|
||||
]
|
||||
fake_modules = {name: types.ModuleType(name) for name in mod_names}
|
||||
|
||||
def _make_figure(*_args, **_kwargs):
|
||||
figure = MagicMock(name="OpenBBFigure")
|
||||
figure.create_subplots.return_value = figure
|
||||
figure.show.return_value.to_plotly_json.return_value = {"plotly": "json"}
|
||||
figure.to_plotly_json.return_value = {"plotly": "json"}
|
||||
return figure
|
||||
|
||||
def _make_ta(*_args, **_kwargs):
|
||||
ta = MagicMock(name="PlotlyTA")
|
||||
ta.plot.return_value = _make_figure()
|
||||
return ta
|
||||
|
||||
def _make_style(*_args, **_kwargs):
|
||||
style = MagicMock(name="ChartStyle")
|
||||
style.plotly_template = {"layout": {}}
|
||||
style.plt_style = "light"
|
||||
return style
|
||||
|
||||
fake_modules["openbb_charting.core.openbb_figure"].OpenBBFigure = _make_figure
|
||||
fake_modules["openbb_charting.core.plotly_ta.ta_class"].PlotlyTA = _make_ta
|
||||
fake_modules["openbb_charting.core.chart_style"].ChartStyle = _make_style
|
||||
fake_modules["openbb_charting.core.to_chart"].to_chart = MagicMock(
|
||||
return_value=(_make_figure(), MagicMock())
|
||||
)
|
||||
fake_modules["openbb_charting.styles.colors"].LARGE_CYCLER = [
|
||||
"red",
|
||||
"blue",
|
||||
"green",
|
||||
] * 50
|
||||
rr_module = fake_modules["openbb_charting.charts.relative_rotation"]
|
||||
rr_module.create_rrg_with_tails = MagicMock(return_value=MagicMock())
|
||||
rr_module.create_rrg_without_tails = MagicMock(return_value=MagicMock())
|
||||
fake_modules["openbb_charting.charts"].relative_rotation = rr_module
|
||||
|
||||
for name, mod in fake_modules.items():
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
|
||||
monkeypatch.delitem(sys.modules, "openbb_technical.technical_views", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ta_accessor(monkeypatch):
|
||||
"""Patch the pandas_ta DataFrame accessor used by _ta_ma."""
|
||||
|
||||
class _FakeTa:
|
||||
def __init__(self, df):
|
||||
self._df = df
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _runner(length=None, offset=None):
|
||||
values = np.arange(1, len(self._df) + 1, dtype="float64")
|
||||
return pd.Series(values, index=self._df.index)
|
||||
|
||||
return _runner
|
||||
|
||||
monkeypatch.setattr(
|
||||
pd.DataFrame,
|
||||
"ta",
|
||||
property(lambda self: _FakeTa(self)),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _records_to_data(records):
|
||||
return [Data(**row) for row in records]
|
||||
|
||||
|
||||
def _rr_obbject(empty=False):
|
||||
obb = MagicMock()
|
||||
if empty:
|
||||
obb.rs_ratios = []
|
||||
obb.rs_momentum = []
|
||||
else:
|
||||
obb.rs_ratios = [
|
||||
Data(date="2021-01-01", SPY=1.0, QQQ=1.1),
|
||||
Data(date="2021-01-02", SPY=1.2, QQQ=1.3),
|
||||
]
|
||||
obb.rs_momentum = [
|
||||
Data(date="2021-01-01", SPY=0.5, QQQ=0.6),
|
||||
Data(date="2021-01-02", SPY=0.7, QQQ=0.8),
|
||||
]
|
||||
obb.benchmark = "SPY"
|
||||
obb.study = "price"
|
||||
return obb
|
||||
|
||||
|
||||
class TestTechnicalSma:
|
||||
def test_uses_dataframe_input(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_sma(data=ohlcv_df.reset_index())
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_existing_ma_type_kwarg_preserved(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_sma(data=ohlcv_df, ma_type="ema")
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalEma:
|
||||
def test_default_ma_type_set(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_ema(data=ohlcv_df)
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalHma:
|
||||
def test_default_ma_type_set(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_hma(data=ohlcv_df)
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalWma:
|
||||
def test_default_ma_type_set(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_wma(data=ohlcv_df)
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalZlma:
|
||||
def test_default_ma_type_set(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_zlma(data=ohlcv_df)
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTaMaHelper:
|
||||
def test_from_obbject_item(self, ohlcv_records, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, content = _ta_ma(obbject_item=_records_to_data(ohlcv_records))
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_from_list_of_data(self, ohlcv_records, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=_records_to_data(ohlcv_records))
|
||||
assert fig is not None
|
||||
|
||||
def test_with_index_column_already_set(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=ohlcv_df, index="date")
|
||||
assert fig is not None
|
||||
|
||||
def test_multiple_ma_types_and_windows(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(
|
||||
data=ohlcv_df.reset_index(),
|
||||
ma_type="sma,ema",
|
||||
length=[10, 20],
|
||||
offset=0,
|
||||
title="Custom",
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_target_fallback_to_close(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=ohlcv_df.reset_index(), target="not_a_column")
|
||||
assert fig is not None
|
||||
|
||||
def test_missing_target_and_close_raises(self, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
df = pd.DataFrame({"price": np.arange(1.0, 100.0)})
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
_ta_ma(data=df, target="missing")
|
||||
|
||||
def test_dropnan_branch(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=ohlcv_df.reset_index(), dropnan=True)
|
||||
assert fig is not None
|
||||
|
||||
def test_candles_branch(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(
|
||||
data=ohlcv_df.reset_index(),
|
||||
candles=True,
|
||||
volume=True,
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_ma_type_none_defaults_to_sma(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=ohlcv_df, ma_type=None, length=None, target=None)
|
||||
assert fig is not None
|
||||
|
||||
def test_dark_theme_branch(self, ohlcv_df, ta_accessor, monkeypatch):
|
||||
from openbb_technical import technical_views
|
||||
|
||||
def _dark_style(*_a, **_k):
|
||||
style = MagicMock()
|
||||
style.plotly_template = {"layout": {}}
|
||||
style.plt_style = "dark"
|
||||
return style
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["openbb_charting.core.chart_style"],
|
||||
"ChartStyle",
|
||||
_dark_style,
|
||||
)
|
||||
fig, _ = technical_views._ta_ma(data=ohlcv_df)
|
||||
assert fig is not None
|
||||
|
||||
def test_scalar_window_int(self, ohlcv_df, ta_accessor):
|
||||
from openbb_technical.technical_views import _ta_ma
|
||||
|
||||
fig, _ = _ta_ma(data=ohlcv_df, length=10)
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalAroon:
|
||||
def test_with_dataframe(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_aroon(
|
||||
data=ohlcv_df, symbol="SPY", volume=True
|
||||
)
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_with_obbject_item(self, ohlcv_records):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_aroon(
|
||||
obbject_item=_records_to_data(ohlcv_records)
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_date_column_reset(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_aroon(data=ohlcv_df.reset_index())
|
||||
assert fig is not None
|
||||
|
||||
def test_multi_symbol_raises(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
df = ohlcv_df.copy()
|
||||
df["symbol"] = ["A", "B"] * (len(df) // 2) + ["A"]
|
||||
with pytest.raises(ValueError, match="one symbol"):
|
||||
TechnicalViews.technical_aroon(data=df)
|
||||
|
||||
|
||||
class TestTechnicalMacd:
|
||||
def test_with_dataframe(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_macd(
|
||||
data=ohlcv_df, symbol="spy", volume=True
|
||||
)
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_with_obbject_item(self, ohlcv_records):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_macd(
|
||||
obbject_item=_records_to_data(ohlcv_records)
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_date_column_reset(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_macd(data=ohlcv_df.reset_index())
|
||||
assert fig is not None
|
||||
|
||||
def test_multi_symbol_raises(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
df = ohlcv_df.copy()
|
||||
df["symbol"] = ["A", "B"] * (len(df) // 2) + ["A"]
|
||||
with pytest.raises(ValueError, match="one symbol"):
|
||||
TechnicalViews.technical_macd(data=df)
|
||||
|
||||
|
||||
class TestTechnicalAdx:
|
||||
def test_with_dataframe(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_adx(data=ohlcv_df, symbol="SPY")
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_with_obbject_item(self, ohlcv_records):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_adx(
|
||||
obbject_item=_records_to_data(ohlcv_records)
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_date_column_reset(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_adx(data=ohlcv_df.reset_index())
|
||||
assert fig is not None
|
||||
|
||||
def test_multi_symbol_raises(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
df = ohlcv_df.copy()
|
||||
df["symbol"] = ["A", "B"] * (len(df) // 2) + ["A"]
|
||||
with pytest.raises(ValueError, match="one symbol"):
|
||||
TechnicalViews.technical_adx(data=df)
|
||||
|
||||
|
||||
class TestTechnicalRsi:
|
||||
def test_with_dataframe(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_rsi(data=ohlcv_df, symbol="spy")
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_with_obbject_item(self, ohlcv_records):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_rsi(
|
||||
obbject_item=_records_to_data(ohlcv_records)
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_date_column_reset(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_rsi(data=ohlcv_df.reset_index())
|
||||
assert fig is not None
|
||||
|
||||
def test_multi_symbol_raises(self, ohlcv_df):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
df = ohlcv_df.copy()
|
||||
df["symbol"] = ["A", "B"] * (len(df) // 2) + ["A"]
|
||||
with pytest.raises(ValueError, match="one symbol"):
|
||||
TechnicalViews.technical_rsi(data=df)
|
||||
|
||||
|
||||
class TestTechnicalCones:
|
||||
def _cones_df(self):
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"window": [3, 10, 30, 60, 90],
|
||||
"realized": [0.1, 0.2, 0.3, 0.25, 0.2],
|
||||
"min": [0.05, 0.1, 0.15, 0.12, 0.1],
|
||||
"median": [0.1, 0.2, 0.25, 0.22, 0.18],
|
||||
"max": [0.2, 0.3, 0.4, 0.35, 0.3],
|
||||
}
|
||||
)
|
||||
|
||||
def test_with_dataframe(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_cones(
|
||||
data=self._cones_df(), symbol="SPY", model="std", title="Cones"
|
||||
)
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_with_obbject_item(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
records = self._cones_df().to_dict(orient="records")
|
||||
items = [Data(**row) for row in records]
|
||||
fig, _ = TechnicalViews.technical_cones(obbject_item=items)
|
||||
assert fig is not None
|
||||
|
||||
def test_defaults_branch(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_cones(data=self._cones_df())
|
||||
assert fig is not None
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
df = pd.DataFrame({"window": [1, 2], "foo": [0.1, 0.2]})
|
||||
with pytest.raises(ValueError, match="expected format"):
|
||||
TechnicalViews.technical_cones(data=df)
|
||||
|
||||
def test_dark_theme_branch(self, monkeypatch):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
def _dark_style(*_a, **_k):
|
||||
style = MagicMock()
|
||||
style.plotly_template = {"layout": {}}
|
||||
style.plt_style = "dark"
|
||||
return style
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["openbb_charting.core.chart_style"],
|
||||
"ChartStyle",
|
||||
_dark_style,
|
||||
)
|
||||
fig, _ = TechnicalViews.technical_cones(data=self._cones_df())
|
||||
assert fig is not None
|
||||
|
||||
|
||||
class TestTechnicalRelativeRotation:
|
||||
def test_with_tails_default(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, content = TechnicalViews.technical_relative_rotation(
|
||||
obbject_item=_rr_obbject(), title="RRG"
|
||||
)
|
||||
assert fig is not None
|
||||
assert isinstance(content, dict)
|
||||
|
||||
def test_show_tails_false_with_date(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_relative_rotation(
|
||||
obbject_item=_rr_obbject(),
|
||||
show_tails=False,
|
||||
date="2021-01-02",
|
||||
study="price",
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_date_forces_no_tails(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
fig, _ = TechnicalViews.technical_relative_rotation(
|
||||
obbject_item=_rr_obbject(),
|
||||
date="2021-01-02",
|
||||
tail_periods=8,
|
||||
tail_interval="day",
|
||||
)
|
||||
assert fig is not None
|
||||
|
||||
def test_empty_data_raises(self):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
with pytest.raises(RuntimeError, match="No data"):
|
||||
TechnicalViews.technical_relative_rotation(
|
||||
obbject_item=_rr_obbject(empty=True)
|
||||
)
|
||||
|
||||
def test_dark_theme_branch(self, monkeypatch):
|
||||
from openbb_technical.technical_views import TechnicalViews
|
||||
|
||||
def _dark_style(*_a, **_k):
|
||||
style = MagicMock()
|
||||
style.plotly_template = {"layout": {}}
|
||||
style.plt_style = "dark"
|
||||
return style
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["openbb_charting.core.chart_style"],
|
||||
"ChartStyle",
|
||||
_dark_style,
|
||||
)
|
||||
fig, _ = TechnicalViews.technical_relative_rotation(obbject_item=_rr_obbject())
|
||||
assert fig is not None
|
||||
3802
openbb_platform/extensions/technical/uv.lock
generated
vendored
Normal file
3802
openbb_platform/extensions/technical/uv.lock
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user