[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:
Danglewood
2026-05-29 00:48:51 -07:00
committed by GitHub
parent cd7d602b8f
commit d3d33a219b
79 changed files with 87090 additions and 6460 deletions

View File

@@ -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

View 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

View 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

View File

@@ -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. |

View 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.

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View 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)

View 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.

View 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"
]
```

View 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.

View 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.

View 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)

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

View 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
1 date open high low close volume
2 2026-01-30 09:30:00 691.7899780273438 693.7100219726562 691.2219848632812 691.6900024414062 10220277
3 2026-01-30 10:00:00 691.6699829101562 694.0499877929688 691.239990234375 691.385009765625 5209301
4 2026-01-30 10:30:00 691.3800048828125 694.2100219726562 690.8900146484375 692.1199951171875 4712297
5 2026-01-30 11:00:00 692.1201171875 692.6900024414062 690.2550048828125 691.0599975585938 4208671
6 2026-01-30 11:30:00 691.0499877929688 692.4299926757812 690.3800048828125 690.6599731445312 3410244
7 2026-01-30 12:00:00 690.6699829101562 691.1900024414062 688.72998046875 691.0700073242188 5237055
8 2026-01-30 12:30:00 691.0650024414062 691.530029296875 689.1300048828125 689.1699829101562 3644964
9 2026-01-30 13:00:00 689.1500244140625 690.280029296875 687.1199951171875 689.469970703125 4445914
10 2026-01-30 13:30:00 689.469970703125 692.0800170898438 687.8499755859375 691.3800048828125 4559168
11 2026-01-30 14:00:00 691.3699951171875 692.7000122070312 691.2999877929688 691.3400268554688 5326403
12 2026-01-30 14:30:00 691.3200073242188 692.280029296875 690.44580078125 690.5700073242188 6437381
13 2026-01-30 15:00:00 690.5750122070312 691.2100219726562 689.2999877929688 690.0399780273438 6534278
14 2026-01-30 15:30:00 690.0700073242188 692.826171875 689.969970703125 691.9000244140625 20608314
15 2026-02-02 09:30:00 689.5800170898438 693.8400268554688 689.4299926757812 692.7000122070312 13387371
16 2026-02-02 10:00:00 692.6199951171875 695.6699829101562 692.3099975585938 694.6849975585938 8585111
17 2026-02-02 10:30:00 694.6849975585938 695.75 694.35498046875 694.72998046875 5022674
18 2026-02-02 11:00:00 694.719970703125 695.6799926757812 693.9199829101562 695.5499877929688 4223929
19 2026-02-02 11:30:00 695.5599975585938 695.8599853515625 694.489990234375 695.75 2876397
20 2026-02-02 12:00:00 695.75 696.52001953125 695.6400146484375 696.2249755859375 3293193
21 2026-02-02 12:30:00 696.2274780273438 696.9299926757812 696.0499877929688 696.405029296875 2164563
22 2026-02-02 13:00:00 696.4099731445312 696.7199096679688 695.6849975585938 696.0601196289062 2519816
23 2026-02-02 13:30:00 696.0700073242188 696.760009765625 696.0250244140625 696.489990234375 2092132
24 2026-02-02 14:00:00 696.489990234375 696.8800048828125 696.3599853515625 696.7899780273438 2886700
25 2026-02-02 14:30:00 696.7849731445312 696.7899780273438 695.344970703125 695.52001953125 4456759
26 2026-02-02 15:00:00 695.52001953125 696.469970703125 695.0103149414062 695.80859375 7707301
27 2026-02-02 15:30:00 695.8099975585938 695.9600219726562 695.0800170898438 695.4600219726562 13121586
28 2026-02-03 09:30:00 696.6412963867188 696.9600219726562 693.3400268554688 693.47998046875 12531281
29 2026-02-03 10:00:00 693.510009765625 693.719970703125 690.7999877929688 692.2050170898438 10174963
30 2026-02-03 10:30:00 692.1900024414062 693.4299926757812 690.9299926757812 693.1400146484375 6765600
31 2026-02-03 11:00:00 693.1400146484375 693.1900024414062 690.6699829101562 690.7999877929688 4239374
32 2026-02-03 11:30:00 690.8099975585938 691.4498901367188 690.27001953125 690.6400146484375 4845241
33 2026-02-03 12:00:00 690.6400146484375 690.739990234375 688.6099853515625 688.8300170898438 7180624
34 2026-02-03 12:30:00 688.8200073242188 688.9400024414062 687.3900146484375 687.7000122070312 5911976
35 2026-02-03 13:00:00 687.6900024414062 688.5499877929688 686.239990234375 686.3599853515625 6658261
36 2026-02-03 13:30:00 686.364990234375 686.4099731445312 684.8499755859375 684.8599853515625 4446502
37 2026-02-03 14:00:00 684.8400268554688 687.219970703125 684.030029296875 686.2135009765625 9707050
38 2026-02-03 14:30:00 686.2100219726562 687.0800170898438 685.1300048828125 686.8300170898438 6391650
39 2026-02-03 15:00:00 686.8200073242188 687.8049926757812 686.280029296875 687.530029296875 6115525
40 2026-02-03 15:30:00 687.530029296875 689.75 687.489990234375 689.4600219726562 12789390
41 2026-02-04 09:30:00 690.3499755859375 691.4500122070312 687.8300170898438 688.1900024414062 11617917
42 2026-02-04 10:00:00 688.1699829101562 690.8499755859375 686.510009765625 689.9000244140625 9058586
43 2026-02-04 10:30:00 689.9000244140625 690.27001953125 687.3200073242188 688.5800170898438 8084299
44 2026-02-04 11:00:00 688.5900268554688 688.9000244140625 686.3900146484375 688.3499145507812 7244032
45 2026-02-04 11:30:00 688.3499755859375 688.47998046875 684.3350219726562 686.5900268554688 7153342
46 2026-02-04 12:00:00 686.5800170898438 687.0999755859375 683.219970703125 683.7000122070312 5057756
47 2026-02-04 12:30:00 683.7000122070312 684.7999877929688 681.760009765625 683.6599731445312 9070977
48 2026-02-04 13:00:00 683.6599731445312 685.010009765625 682.3300170898438 684.7999877929688 5538423
49 2026-02-04 13:30:00 684.8099975585938 685.510009765625 683.1099853515625 684.6300048828125 5337075
50 2026-02-04 14:00:00 684.6199951171875 685.405029296875 683.4000244140625 685.3400268554688 5111337
51 2026-02-04 14:30:00 685.3400268554688 688.47998046875 685.2999877929688 688.27001953125 7759218
52 2026-02-04 15:00:00 688.27001953125 688.489990234375 686.7449951171875 687.5399780273438 5601565
53 2026-02-04 15:30:00 687.530029296875 688.3200073242188 685.6409912109375 686.1199951171875 10951574
54 2026-02-05 09:30:00 680.9400024414062 683.6900024414062 678.760009765625 680.6099853515625 15721856
55 2026-02-05 10:00:00 680.6099853515625 681.3800048828125 676.1749877929688 676.6400146484375 11679234
56 2026-02-05 10:30:00 676.6500244140625 678.489990234375 675.9400024414062 677.3300170898438 9818293
57 2026-02-05 11:00:00 677.3300170898438 679.7899780273438 675.7899780273438 679.6599731445312 7852513
58 2026-02-05 11:30:00 679.6599731445312 681.5349731445312 678.9500122070312 681.239990234375 7074267
59 2026-02-05 12:00:00 681.219970703125 681.9400024414062 678.97998046875 681.5 4825813
60 2026-02-05 12:30:00 681.52001953125 682.614990234375 680.1699829101562 680.4500122070312 3960858
61 2026-02-05 13:00:00 680.4600219726562 681.3200073242188 678.8499755859375 679.1500244140625 3837909
62 2026-02-05 13:30:00 679.1599731445312 680.010009765625 677.6849975585938 678.30029296875 3852075
63 2026-02-05 14:00:00 678.3099975585938 681.469970703125 678.030029296875 680.2449951171875 6518488
64 2026-02-05 14:30:00 680.239990234375 680.239990234375 678.3900146484375 679.0900268554688 4632347
65 2026-02-05 15:00:00 679.0900268554688 680.89501953125 677.6400146484375 677.77001953125 6639130
66 2026-02-05 15:30:00 677.7899780273438 678.3499145507812 676.1854858398438 677.4400024414062 14065824
67 2026-02-06 09:30:00 681.4600219726562 686.510009765625 680.8499755859375 684.7449951171875 17564509
68 2026-02-06 10:00:00 684.7999877929688 685.8099975585938 683.1099853515625 685.6500244140625 7885250
69 2026-02-06 10:30:00 685.6500244140625 687.0399780273438 684.3300170898438 685.7999877929688 7352844
70 2026-02-06 11:00:00 685.7899780273438 687.719970703125 685.4299926757812 687.10498046875 6075477
71 2026-02-06 11:30:00 687.10498046875 688.010009765625 686.9400024414062 687.4600219726562 4187890
72 2026-02-06 12:00:00 687.4550170898438 688.510009765625 687.1400146484375 688.47998046875 3302822
73 2026-02-06 12:30:00 688.5 689.5399780273438 687.8524169921875 688.9500122070312 3849214
74 2026-02-06 13:00:00 688.9600219726562 689.510009765625 688.5900268554688 688.9450073242188 3014684
75 2026-02-06 13:30:00 688.9500122070312 689.6599731445312 688.8800048828125 689.030029296875 2553507
76 2026-02-06 14:00:00 689.030029296875 689.530029296875 688.739990234375 689.47802734375 3194613
77 2026-02-06 14:30:00 689.47998046875 689.760009765625 688.9000244140625 689.3499755859375 3096524
78 2026-02-06 15:00:00 689.3400268554688 690.1400146484375 689.0999755859375 689.7750244140625 4617208
79 2026-02-06 15:30:00 689.77001953125 692.3099975585938 689.5999755859375 690.5999755859375 14828433
80 2026-02-09 09:30:00 689.4199829101562 691.635009765625 688.3400268554688 690.4099731445312 12225578
81 2026-02-09 10:00:00 690.4000244140625 692.9299926757812 690.219970703125 691.9400024414062 5824477
82 2026-02-09 10:30:00 691.9600219726562 694.4600219726562 691.938720703125 694.239990234375 5639155
83 2026-02-09 11:00:00 694.22998046875 694.760009765625 694.0599975585938 694.689697265625 4640584
84 2026-02-09 11:30:00 694.6799926757812 695.5900268554688 694.27001953125 695.3900146484375 3716499
85 2026-02-09 12:00:00 695.385009765625 695.6599731445312 695.1199951171875 695.1400146484375 2636026
86 2026-02-09 12:30:00 695.1500244140625 695.510009765625 694.47998046875 694.5800170898438 2156392
87 2026-02-09 13:00:00 694.5700073242188 695.0 693.5499877929688 694.22998046875 3609265
88 2026-02-09 13:30:00 694.22998046875 695.1900024414062 694.0 694.6699829101562 2534025
89 2026-02-09 14:00:00 694.6699829101562 695.8699951171875 694.489990234375 695.739990234375 3270140
90 2026-02-09 14:30:00 695.739990234375 695.8499755859375 695.3400268554688 695.4199829101562 2466299
91 2026-02-09 15:00:00 695.4199829101562 695.719970703125 694.3300170898438 694.5750122070312 4605472
92 2026-02-09 15:30:00 694.5700073242188 695.02001953125 693.8800048828125 693.9099731445312 11568140
93 2026-02-10 09:30:00 694.9500122070312 696.1599731445312 693.4550170898438 695.75 10045799
94 2026-02-10 10:00:00 695.739990234375 696.4349975585938 694.7100219726562 695.9199829101562 5005896
95 2026-02-10 10:30:00 695.905029296875 696.5399780273438 694.9099731445312 695.39501953125 3452116
96 2026-02-10 11:00:00 695.4000244140625 695.6699829101562 694.0900268554688 694.780029296875 3801252
97 2026-02-10 11:30:00 694.77001953125 695.4000244140625 694.3200073242188 694.77001953125 2419112
98 2026-02-10 12:00:00 694.7550048828125 695.6701049804688 694.2000122070312 695.25 3299994
99 2026-02-10 12:30:00 695.25 695.7999877929688 694.8250122070312 695.4412841796875 1686491
100 2026-02-10 13:00:00 695.4500122070312 695.469970703125 693.3699951171875 693.4400024414062 3267938
101 2026-02-10 13:30:00 693.4299926757812 693.8599853515625 692.8499755859375 693.6400146484375 4082507
102 2026-02-10 14:00:00 693.6500244140625 693.905029296875 693.1300048828125 693.8699951171875 3008775
103 2026-02-10 14:30:00 693.8699951171875 694.1300048828125 693.3599853515625 693.77001953125 2292104
104 2026-02-10 15:00:00 693.760009765625 693.9929809570312 693.1099853515625 693.2974853515625 3190462
105 2026-02-10 15:30:00 693.2999877929688 693.5399780273438 691.6599731445312 692.1099853515625 13770742
106 2026-02-11 09:30:00 696.3900146484375 697.1300048828125 693.8049926757812 694.1199951171875 10890040
107 2026-02-11 10:00:00 694.1300048828125 694.760009765625 689.1799926757812 689.719970703125 10524651
108 2026-02-11 10:30:00 689.719970703125 692.6199951171875 689.5599975585938 692.3499755859375 7335912
109 2026-02-11 11:00:00 692.3300170898438 693.4500122070312 691.3800048828125 691.8400268554688 4175798
110 2026-02-11 11:30:00 691.8200073242188 693.3499755859375 691.5 692.0700073242188 3807619
111 2026-02-11 12:00:00 692.0499877929688 693.8400268554688 691.3900146484375 693.5969848632812 4171905
112 2026-02-11 12:30:00 693.5999755859375 693.989990234375 692.5499877929688 692.77001953125 2481578
113 2026-02-11 13:00:00 692.77001953125 692.97998046875 691.4099731445312 692.7899780273438 3974008
114 2026-02-11 13:30:00 692.7896728515625 693.02001953125 692.030029296875 692.72998046875 2966854
115 2026-02-11 14:00:00 692.72998046875 693.9550170898438 692.1099853515625 693.739990234375 3858562
116 2026-02-11 14:30:00 693.75 693.8599853515625 692.4199829101562 692.9400024414062 4196520
117 2026-02-11 15:00:00 692.9199829101562 693.0999755859375 692.22998046875 692.2550048828125 4124062
118 2026-02-11 15:30:00 692.25 692.989990234375 691.77001953125 691.9199829101562 9384677
119 2026-02-12 09:30:00 694.239990234375 695.3499755859375 692.3200073242188 692.8300170898438 10208980
120 2026-02-12 10:00:00 692.8300170898438 693.72998046875 692.1599731445312 693.0650024414062 5436749
121 2026-02-12 10:30:00 693.0800170898438 693.47998046875 688.5850219726562 688.6500244140625 7705275
122 2026-02-12 11:00:00 688.6500244140625 688.8049926757812 683.5750122070312 683.5999755859375 15158331
123 2026-02-12 11:30:00 683.594970703125 685.8400268554688 683.5250244140625 684.6400146484375 9915277
124 2026-02-12 12:00:00 684.6300048828125 685.27001953125 683.3800048828125 684.1199951171875 7043293
125 2026-02-12 12:30:00 684.0999755859375 685.5750122070312 682.9500122070312 682.989990234375 4902663
126 2026-02-12 13:00:00 682.989990234375 684.1199951171875 682.219970703125 683.1699829101562 5523603
127 2026-02-12 13:30:00 683.1649780273438 685.3250122070312 682.7999877929688 684.7100219726562 5538416
128 2026-02-12 14:00:00 684.7000122070312 685.4199829101562 683.594970703125 684.1500244140625 5166108
129 2026-02-12 14:30:00 684.1300048828125 685.9650268554688 684.1199951171875 685.1599731445312 5560468
130 2026-02-12 15:00:00 685.1799926757812 685.5399780273438 683.1099853515625 683.219970703125 6211522
131 2026-02-12 15:30:00 683.2000122070312 683.4000244140625 680.3699951171875 681.219970703125 19241913
132 2026-02-13 09:30:00 681.6900024414062 683.3200073242188 677.52001953125 679.9299926757812 17481343
133 2026-02-13 10:00:00 679.9099731445312 683.3699951171875 679.7767944335938 682.9400024414062 7287512
134 2026-02-13 10:30:00 682.9400024414062 684.5700073242188 680.7899780273438 682.5202026367188 7278187
135 2026-02-13 11:00:00 682.52001953125 684.219970703125 681.8300170898438 683.8200073242188 4957855
136 2026-02-13 11:30:00 683.8099975585938 685.510009765625 682.8699951171875 684.75 4310262
137 2026-02-13 12:00:00 684.75 686.0499877929688 684.47998046875 685.364990234375 3989696
138 2026-02-13 12:30:00 685.3800048828125 686.280029296875 684.8599853515625 685.0999755859375 3513166
139 2026-02-13 13:00:00 685.0999755859375 686.260009765625 684.0900268554688 685.3900146484375 4433585
140 2026-02-13 13:30:00 685.3900146484375 686.0599975585938 684.3699951171875 684.6599731445312 3433240
141 2026-02-13 14:00:00 684.6699829101562 685.364990234375 684.0999755859375 684.3400268554688 3580111
142 2026-02-13 14:30:00 684.3499755859375 684.9299926757812 682.239990234375 682.4000244140625 4271047
143 2026-02-13 15:00:00 682.4000244140625 683.260009765625 680.3400268554688 680.6300048828125 6092813
144 2026-02-13 15:30:00 680.6400146484375 681.8400268554688 679.8099975585938 681.5999755859375 13671717
145 2026-02-17 09:30:00 680.1400146484375 682.7999877929688 676.4600219726562 676.6300048828125 13581128
146 2026-02-17 10:00:00 676.6199951171875 679.75 676.27001953125 677.3400268554688 7327743
147 2026-02-17 10:30:00 677.3300170898438 678.6400146484375 675.780029296875 678.6099853515625 6383432
148 2026-02-17 11:00:00 678.5999755859375 683.239990234375 678.4500122070312 681.5999755859375 8896099
149 2026-02-17 11:30:00 681.5750122070312 683.030029296875 680.8099975585938 682.4600219726562 4749903
150 2026-02-17 12:00:00 682.4199829101562 682.5399780273438 680.4312744140625 682.2000122070312 3474041
151 2026-02-17 12:30:00 682.2100219726562 682.3200073242188 679.1500244140625 681.0103149414062 3953490
152 2026-02-17 13:00:00 681.02001953125 682.5399780273438 679.8300170898438 682.3900146484375 3099031
153 2026-02-17 13:30:00 682.4000244140625 684.7150268554688 682.030029296875 684.0750122070312 4185741
154 2026-02-17 14:00:00 684.0800170898438 684.6199951171875 683.5800170898438 683.989990234375 3248663
155 2026-02-17 14:30:00 683.969970703125 684.9400024414062 683.4099731445312 683.5700073242188 2905047
156 2026-02-17 15:00:00 683.5700073242188 684.0999755859375 682.6699829101562 682.8699951171875 4068800
157 2026-02-17 15:30:00 682.8800048828125 683.2899780273438 681.9299926757812 682.75 9895361
158 2026-02-18 09:30:00 684.02001953125 686.3800048828125 682.8300170898438 686.1599731445312 10377461
159 2026-02-18 10:00:00 686.1699829101562 688.4650268554688 686.0850219726562 687.8400268554688 8075844
160 2026-02-18 10:30:00 687.8400268554688 688.7249755859375 687.2899780273438 688.5150146484375 3499843
161 2026-02-18 11:00:00 688.52001953125 688.9600219726562 688.02001953125 688.219970703125 3192040
162 2026-02-18 11:30:00 688.219970703125 688.760009765625 687.4998779296875 687.530029296875 2432679
163 2026-02-18 12:00:00 687.530029296875 688.8900146484375 687.3800048828125 688.2000122070312 2967596
164 2026-02-18 12:30:00 688.1900024414062 688.7999877929688 687.8900146484375 688.5009765625 1985899
165 2026-02-18 13:00:00 688.5 689.1500244140625 687.4749755859375 687.8049926757812 2405600
166 2026-02-18 13:30:00 687.8099975585938 688.02001953125 686.0399780273438 686.97998046875 4123682
167 2026-02-18 14:00:00 686.989990234375 687.3900146484375 686.0700073242188 686.4199829101562 4265139
168 2026-02-18 14:30:00 686.4299926757812 686.6300048828125 684.0499877929688 684.5999755859375 5521451
169 2026-02-18 15:00:00 684.5800170898438 685.3599853515625 683.9199829101562 684.2100219726562 4614814
170 2026-02-18 15:30:00 684.2100219726562 687.1799926757812 684.1199951171875 686.1900024414062 13217594
171 2026-02-19 09:30:00 683.8400268554688 685.8200073242188 682.469970703125 685.010009765625 9809956
172 2026-02-19 10:00:00 685.0 686.1799926757812 683.8099975585938 683.9000244140625 5484387
173 2026-02-19 10:30:00 683.8900146484375 685.8599853515625 683.7100219726562 685.3200073242188 3259468
174 2026-02-19 11:00:00 685.3200073242188 685.8300170898438 683.9000244140625 684.14501953125 2655141
175 2026-02-19 11:30:00 684.1500244140625 685.27001953125 682.719970703125 683.52001953125 3640329
176 2026-02-19 12:00:00 683.5250244140625 684.780029296875 682.7000122070312 683.9299926757812 2960917
177 2026-02-19 12:30:00 683.9149780273438 684.260009765625 682.1300048828125 682.4400024414062 2830254
178 2026-02-19 13:00:00 682.4199829101562 683.4199829101562 681.5474853515625 682.4000244140625 3304400
179 2026-02-19 13:30:00 682.3400268554688 683.8800048828125 682.010009765625 683.739990234375 2668632
180 2026-02-19 14:00:00 683.739990234375 684.0598754882812 683.0 683.2750244140625 2531001
181 2026-02-19 14:30:00 683.25 683.5650024414062 681.8800048828125 682.5599975585938 3425329
182 2026-02-19 15:00:00 682.530029296875 683.489990234375 682.52001953125 682.9000244140625 4468918
183 2026-02-19 15:30:00 682.8900146484375 684.5900268554688 682.8001098632812 684.510009765625 7648304
184 2026-02-20 09:30:00 682.3200073242188 685.3499755859375 681.739990234375 684.8499755859375 9151776
185 2026-02-20 10:00:00 684.8900146484375 689.4000244140625 684.1900024414062 686.7999877929688 11328318
186 2026-02-20 10:30:00 686.7999877929688 689.1699829101562 685.510009765625 688.8300170898438 5860027
187 2026-02-20 11:00:00 688.8200073242188 689.239990234375 686.0900268554688 686.1495971679688 11087726
188 2026-02-20 11:30:00 686.1300048828125 689.1500244140625 685.8900146484375 688.5700073242188 6362378
189 2026-02-20 12:00:00 688.5700073242188 688.8699951171875 684.0800170898438 685.2291870117188 5881952
190 2026-02-20 12:30:00 685.2349853515625 687.7100219726562 684.9700927734375 687.47998046875 4927923
191 2026-02-20 13:00:00 687.4299926757812 687.7999877929688 685.717529296875 686.5599975585938 4217967
192 2026-02-20 13:30:00 686.5399780273438 690.0599975585938 685.760009765625 689.2999877929688 9260818
193 2026-02-20 14:00:00 689.2999877929688 689.7899780273438 688.239990234375 688.5800170898438 3936078
194 2026-02-20 14:30:00 688.5800170898438 689.1199951171875 687.8099975585938 688.0499877929688 3577083
195 2026-02-20 15:00:00 688.0599975585938 689.3200073242188 688.0050048828125 688.3599853515625 4449935
196 2026-02-20 15:30:00 688.3599853515625 689.5 688.27001953125 689.3499755859375 10691604
197 2026-02-23 09:30:00 687.8300170898438 690.0 686.2999877929688 686.530029296875 12006960
198 2026-02-23 10:00:00 686.5 687.22998046875 684.2799072265625 684.510009765625 8045462
199 2026-02-23 10:30:00 684.5 685.0050048828125 682.1799926757812 682.260009765625 7093687
200 2026-02-23 11:00:00 682.27001953125 682.9099731445312 680.3709716796875 682.8699951171875 8309823
201 2026-02-23 11:30:00 682.8800048828125 683.0850219726562 681.780029296875 681.9600219726562 4423053
202 2026-02-23 12:00:00 681.969970703125 682.469970703125 681.5900268554688 681.6900024414062 2659647
203 2026-02-23 12:30:00 681.7000122070312 682.3499755859375 680.5900268554688 682.0449829101562 4149150
204 2026-02-23 13:00:00 682.0399780273438 683.9500122070312 681.9400024414062 683.8098754882812 3525018
205 2026-02-23 13:30:00 683.8099975585938 683.9099731445312 681.8699951171875 682.010009765625 3280170
206 2026-02-23 14:00:00 682.02001953125 682.280029296875 681.1699829101562 681.27001953125 4208010
207 2026-02-23 14:30:00 681.2550048828125 681.6599731445312 680.5399780273438 681.0599975585938 3879543
208 2026-02-23 15:00:00 681.0599975585938 681.52001953125 680.6500244140625 681.3499755859375 4474731
209 2026-02-23 15:30:00 681.3400268554688 682.469970703125 680.9000244140625 682.4099731445312 15739631
210 2026-02-24 09:30:00 681.9000244140625 685.4600219726562 680.0 683.1500244140625 12315459
211 2026-02-24 10:00:00 683.1799926757812 686.3900146484375 682.030029296875 686.3350219726562 6900473
212 2026-02-24 10:30:00 686.3200073242188 686.5449829101562 685.1799926757812 685.3699951171875 5177894
213 2026-02-24 11:00:00 685.3699951171875 686.8699951171875 685.2921142578125 686.0900268554688 3737538
214 2026-02-24 11:30:00 686.0999755859375 686.7000122070312 685.1500244140625 686.469970703125 3211549
215 2026-02-24 12:00:00 686.4600219726562 687.7999877929688 686.4000244140625 687.5300903320312 3558364
216 2026-02-24 12:30:00 687.530029296875 687.6599731445312 686.9619750976562 687.3699951171875 2311462
217 2026-02-24 13:00:00 687.3599853515625 688.0999755859375 686.9400024414062 687.030029296875 2108681
218 2026-02-24 13:30:00 687.0399780273438 687.875 686.905029296875 687.4598999023438 2449007
219 2026-02-24 14:00:00 687.4500122070312 687.5999755859375 686.6500244140625 686.8900146484375 3798412
220 2026-02-24 14:30:00 686.8900146484375 687.6900024414062 686.780029296875 687.135009765625 2689633
221 2026-02-24 15:00:00 687.135009765625 687.3400268554688 686.4600219726562 686.5399780273438 3453132
222 2026-02-24 15:30:00 686.530029296875 688.3499755859375 686.5100708007812 687.375 12579342
223 2026-02-25 09:30:00 690.1799926757812 691.989990234375 690.0999755859375 691.3200073242188 7845111
224 2026-02-25 10:00:00 691.3200073242188 691.5800170898438 690.25 691.1500244140625 4002640
225 2026-02-25 10:30:00 691.14501953125 691.3499755859375 690.1701049804688 690.8400268554688 2547963
226 2026-02-25 11:00:00 690.8300170898438 691.5800170898438 690.6900024414062 691.469970703125 2645311
227 2026-02-25 11:30:00 691.47998046875 692.3900146484375 691.4000244140625 692.0399780273438 3263681
228 2026-02-25 12:00:00 692.0399780273438 692.3499755859375 691.7999877929688 691.969970703125 2214444
229 2026-02-25 12:30:00 691.969970703125 692.5700073242188 691.8400268554688 692.5499877929688 2218727
230 2026-02-25 13:00:00 692.5499877929688 692.6900024414062 692.2050170898438 692.3099975585938 2694920
231 2026-02-25 13:30:00 692.3099975585938 692.6900024414062 692.2100219726562 692.489990234375 2007932
232 2026-02-25 14:00:00 692.489990234375 693.0800170898438 692.47998046875 692.8699951171875 3206296
233 2026-02-25 14:30:00 692.8599853515625 693.3200073242188 692.8499755859375 693.0850219726562 2612958
234 2026-02-25 15:00:00 693.0850219726562 693.3300170898438 692.8099975585938 692.9199829101562 3947813
235 2026-02-25 15:30:00 692.9199829101562 693.6799926757812 692.739990234375 693.0999755859375 10495870
236 2026-02-26 09:30:00 693.280029296875 693.2999877929688 690.4199829101562 690.6699829101562 8814758
237 2026-02-26 10:00:00 690.6799926757812 691.030029296875 684.739990234375 685.6900024414062 11044556
238 2026-02-26 10:30:00 685.6699829101562 688.1799926757812 684.3499755859375 687.7100219726562 7481798
239 2026-02-26 11:00:00 687.719970703125 689.0999755859375 687.4600219726562 688.739990234375 3693800
240 2026-02-26 11:30:00 688.739990234375 688.9199829101562 686.0349731445312 686.25 3917564
241 2026-02-26 12:00:00 686.239990234375 687.0599975585938 686.030029296875 686.2849731445312 2601143
242 2026-02-26 12:30:00 686.25 687.0900268554688 685.010009765625 685.4600219726562 2631031
243 2026-02-26 13:00:00 685.469970703125 685.9550170898438 684.739990234375 685.6699829101562 2885475
244 2026-02-26 13:30:00 685.6599731445312 688.1900024414062 685.4099731445312 687.3900146484375 4488188
245 2026-02-26 14:00:00 687.4099731445312 689.3200073242188 687.1599731445312 688.6099853515625 3927779
246 2026-02-26 14:30:00 688.6199951171875 689.6599731445312 688.469970703125 689.02001953125 3333412
247 2026-02-26 15:00:00 689.010009765625 689.8099975585938 688.614990234375 688.6649780273438 3717526
248 2026-02-26 15:30:00 688.6649780273438 689.5167236328125 688.6199951171875 689.3200073242188 8572144
249 2026-02-27 09:30:00 683.0900268554688 684.25 681.6400146484375 682.8663940429688 11392713
250 2026-02-27 10:00:00 682.8599853515625 685.489990234375 682.6599731445312 684.5294189453125 6379720
251 2026-02-27 10:30:00 684.510009765625 686.0499877929688 683.739990234375 685.219970703125 3556716
252 2026-02-27 11:00:00 685.239990234375 686.0499877929688 684.6599731445312 685.385009765625 3484183
253 2026-02-27 11:30:00 685.3900146484375 686.2899169921875 684.5650024414062 684.8699951171875 3290521
254 2026-02-27 12:00:00 684.8599853515625 686.280029296875 683.2899780273438 684.1400146484375 4210127
255 2026-02-27 12:30:00 684.1300048828125 685.1199951171875 683.4755249023438 684.375 4491724
256 2026-02-27 13:00:00 684.3699951171875 684.6799926757812 683.0599975585938 683.7999877929688 2922758
257 2026-02-27 13:30:00 683.7999877929688 684.239990234375 683.2999877929688 683.9500122070312 2479731
258 2026-02-27 14:00:00 683.9500122070312 684.97998046875 683.47998046875 683.5999755859375 3194256
259 2026-02-27 14:30:00 683.5999755859375 684.030029296875 682.989990234375 683.3499755859375 3404117
260 2026-02-27 15:00:00 683.3400268554688 686.0999755859375 683.3200073242188 685.590087890625 6686482
261 2026-02-27 15:30:00 685.5800170898438 686.8499755859375 684.489990234375 686.280029296875 15978632
262 2026-03-02 09:30:00 678.7000122070312 683.3699951171875 678.02001953125 683.0 13517630
263 2026-03-02 10:00:00 682.9500122070312 685.3599853515625 682.4000244140625 683.4299926757812 8599643
264 2026-03-02 10:30:00 683.4199829101562 684.8400268554688 682.4199829101562 684.2100219726562 5287026
265 2026-03-02 11:00:00 684.1900024414062 685.6699829101562 682.9500122070312 683.219970703125 5536917
266 2026-03-02 11:30:00 683.219970703125 686.969970703125 682.9199829101562 686.2349853515625 5560334
267 2026-03-02 12:00:00 686.2349853515625 687.1900024414062 685.7100219726562 686.4837036132812 3273553
268 2026-03-02 12:30:00 686.47998046875 687.3099975585938 686.0800170898438 687.2199096679688 2519098
269 2026-03-02 13:00:00 687.219970703125 687.4199829101562 686.1002197265625 686.719970703125 2029992
270 2026-03-02 13:30:00 686.75 686.8400268554688 685.2249755859375 685.280029296875 2801097
271 2026-03-02 14:00:00 685.27001953125 688.280029296875 685.1900024414062 688.2000122070312 4229405
272 2026-03-02 14:30:00 688.2100219726562 688.6199951171875 687.969970703125 688.1950073242188 3144600
273 2026-03-02 15:00:00 688.1900024414062 688.3300170898438 684.8900146484375 685.3499755859375 10018761
274 2026-03-02 15:30:00 685.3499755859375 687.3099975585938 685.2000122070312 686.3699951171875 12691271
275 2026-03-03 09:30:00 675.0599975585938 677.0999755859375 671.9500122070312 672.3800048828125 20101160
276 2026-03-03 10:00:00 672.3599853515625 673.9299926757812 669.6599731445312 669.8499755859375 13021771
277 2026-03-03 10:30:00 669.8499755859375 672.5 669.780029296875 671.97998046875 9207591
278 2026-03-03 11:00:00 671.9600219726562 676.5800170898438 671.75 676.02001953125 8016859
279 2026-03-03 11:30:00 676.0 678.4500122070312 674.6599731445312 677.5599975585938 6793621
280 2026-03-03 12:00:00 677.5700073242188 678.489990234375 676.3699951171875 678.3200073242188 3787635
281 2026-03-03 12:30:00 678.3200073242188 678.4739990234375 676.0800170898438 677.4199829101562 3752319
282 2026-03-03 13:00:00 677.3900146484375 680.4299926757812 677.3800048828125 679.8200073242188 3853466
283 2026-03-03 13:30:00 679.8200073242188 681.9099731445312 679.6699829101562 681.260009765625 5499252
284 2026-03-03 14:00:00 681.239990234375 681.52001953125 679.6649780273438 680.5 3859389
285 2026-03-03 14:30:00 680.5 682.6099853515625 679.2750244140625 681.2329711914062 6675010
286 2026-03-03 15:00:00 681.25 681.9500122070312 680.1599731445312 681.0499877929688 4816306
287 2026-03-03 15:30:00 681.0399780273438 681.510009765625 679.2100219726562 680.0399780273438 6319699
288 2026-03-04 09:30:00 681.6300048828125 683.5180053710938 679.6199951171875 681.302978515625 9849889
289 2026-03-04 10:00:00 681.4099731445312 686.27001953125 680.8699951171875 686.1400146484375 7242226
290 2026-03-04 10:30:00 686.1500244140625 686.3099975585938 683.8800048828125 685.8300170898438 6268576
291 2026-03-04 11:00:00 685.8400268554688 686.6400146484375 684.1799926757812 685.1900024414062 6472934
292 2026-03-04 11:30:00 685.1799926757812 686.4000244140625 684.8499755859375 685.780029296875 4337235
293 2026-03-04 12:00:00 685.7949829101562 686.3599853515625 685.3099975585938 685.3250122070312 3369534
294 2026-03-04 12:30:00 685.3499755859375 686.4199829101562 684.5800170898438 686.1900024414062 3863509
295 2026-03-04 13:00:00 686.2000122070312 686.489990234375 685.3800048828125 686.10498046875 3051554
296 2026-03-04 13:30:00 686.1099853515625 686.8699951171875 686.0999755859375 686.5449829101562 2931283
297 2026-03-04 14:00:00 686.5499877929688 686.9600219726562 686.3900146484375 686.5999755859375 2221319
298 2026-03-04 14:30:00 686.6099853515625 687.0900268554688 686.4450073242188 686.8099975585938 2684793
299 2026-03-04 15:00:00 686.8150024414062 687.0050048828125 685.4749755859375 686.22998046875 4228708
300 2026-03-04 15:30:00 686.239990234375 686.6300048828125 685.1599731445312 685.1649780273438 11184616
301 2026-03-05 09:30:00 682.0800170898438 685.1011962890625 681.8699951171875 685.0999755859375 10670019
302 2026-03-05 10:00:00 685.0900268554688 685.530029296875 680.3300170898438 680.8200073242188 9045328
303 2026-03-05 10:30:00 680.8300170898438 682.5800170898438 679.3200073242188 682.1099853515625 7439675
304 2026-03-05 11:00:00 682.0999755859375 682.3599853515625 679.010009765625 679.5349731445312 8573727
305 2026-03-05 11:30:00 679.530029296875 681.3499755859375 679.4600219726562 681.0599975585938 5576246
306 2026-03-05 12:00:00 681.0549926757812 682.4199829101562 678.489990234375 678.510009765625 5386953
307 2026-03-05 12:30:00 678.510009765625 678.8049926757812 676.030029296875 676.4600219726562 7947362
308 2026-03-05 13:00:00 676.4500122070312 678.260009765625 675.9000244140625 676.2999877929688 4862016
309 2026-03-05 13:30:00 676.280029296875 677.9600219726562 675.8200073242188 676.6199951171875 4006006
310 2026-03-05 14:00:00 676.5999755859375 677.7899780273438 676.2899780273438 676.6699829101562 4123417
311 2026-03-05 14:30:00 676.6699829101562 677.72998046875 675.6099853515625 677.0900268554688 4262287
312 2026-03-05 15:00:00 677.0700073242188 680.4600219726562 676.3900146484375 679.4600219726562 8441210
313 2026-03-05 15:30:00 679.469970703125 681.75 679.1699829101562 681.4400024414062 17275765
314 2026-03-06 09:30:00 673.4099731445312 674.0599975585938 669.760009765625 670.8699951171875 18169619
315 2026-03-06 10:00:00 670.9099731445312 674.3599853515625 670.791015625 674.1699829101562 7876730
316 2026-03-06 10:30:00 674.1300048828125 675.9149780273438 672.4600219726562 674.9199829101562 7095966
317 2026-03-06 11:00:00 674.9600219726562 676.010009765625 673.4349975585938 674.4199829101562 4958922
318 2026-03-06 11:30:00 674.4299926757812 676.0 673.4000244140625 674.969970703125 3958006
319 2026-03-06 12:00:00 674.97998046875 675.8400268554688 673.3499755859375 673.8099975585938 3906929
320 2026-03-06 12:30:00 673.7999877929688 674.1599731445312 672.030029296875 673.0800170898438 4299727
321 2026-03-06 13:00:00 673.0900268554688 676.1099853515625 672.969970703125 674.6400146484375 5087813
322 2026-03-06 13:30:00 674.6699829101562 675.2999877929688 673.6900024414062 674.0399780273438 4250499
323 2026-03-06 14:00:00 674.0700073242188 675.4299926757812 673.780029296875 674.9199829101562 3370425
324 2026-03-06 14:30:00 674.9099731445312 675.6400146484375 674.1799926757812 675.010009765625 4736793
325 2026-03-06 15:00:00 675.0 675.969970703125 671.97998046875 672.22998046875 7386159
326 2026-03-06 15:30:00 672.22998046875 673.4000244140625 671.1400146484375 672.489990234375 16549538
327 2026-03-09 09:30:00 666.3900146484375 667.6300048828125 662.7000122070312 664.0800170898438 13542871
328 2026-03-09 10:00:00 664.0499877929688 665.9500122070312 662.3900146484375 664.0599975585938 7962422
329 2026-03-09 10:30:00 664.030029296875 668.3099975585938 663.97998046875 667.5800170898438 6056607
330 2026-03-09 11:00:00 667.5800170898438 669.9199829101562 667.5800170898438 669.77001953125 5489484
331 2026-03-09 11:30:00 669.77001953125 669.8900146484375 668.3699951171875 669.4450073242188 4434002
332 2026-03-09 12:00:00 669.4400024414062 671.5999755859375 669.169921875 670.8699951171875 4434300
333 2026-03-09 12:30:00 670.8699951171875 672.25 669.8900146484375 671.2698974609375 4638323
334 2026-03-09 13:00:00 671.2750244140625 671.3599853515625 668.7100219726562 668.844970703125 4269224
335 2026-03-09 13:30:00 668.8400268554688 669.3400268554688 668.0900268554688 668.8150024414062 3915778
336 2026-03-09 14:00:00 668.8400268554688 670.8699951171875 668.77001953125 670.0599975585938 4276846
337 2026-03-09 14:30:00 670.0599975585938 671.2999877929688 669.9378051757812 670.0800170898438 4130423
338 2026-03-09 15:00:00 670.0999755859375 677.5150146484375 669.9000244140625 677.3900146484375 12772758
339 2026-03-09 15:30:00 677.3699951171875 679.9199829101562 675.8800048828125 678.2999877929688 16099713
340 2026-03-10 09:30:00 677.719970703125 678.7849731445312 675.489990234375 675.8499755859375 9403089
341 2026-03-10 10:00:00 675.8300170898438 678.0499877929688 674.760009765625 677.6199951171875 4681281
342 2026-03-10 10:30:00 677.6400146484375 681.1599731445312 677.6300048828125 681.1400146484375 5042469
343 2026-03-10 11:00:00 681.1300048828125 681.6849975585938 680.22998046875 681.1099853515625 4361068
344 2026-03-10 11:30:00 681.0999755859375 681.9498901367188 680.4400024414062 681.3699951171875 3389571
345 2026-03-10 12:00:00 681.3599853515625 681.4000244140625 679.6799926757812 680.5999755859375 3873397
346 2026-03-10 12:30:00 680.6099853515625 681.8200073242188 680.6099853515625 681.4500122070312 4146661
347 2026-03-10 13:00:00 681.4500122070312 683.3599853515625 681.27001953125 683.2650146484375 4944269
348 2026-03-10 13:30:00 683.25 683.25 680.6300048828125 682.0469970703125 4947798
349 2026-03-10 14:00:00 682.0499877929688 682.1900024414062 677.3800048828125 679.3499755859375 10262003
350 2026-03-10 14:30:00 679.3150024414062 680.3699951171875 678.3800048828125 678.5 4689766
351 2026-03-10 15:00:00 678.52001953125 679.530029296875 677.4099731445312 677.4099731445312 4818131
352 2026-03-10 15:30:00 677.4199829101562 678.3200073242188 676.22998046875 677.0499877929688 10409992
353 2026-03-11 09:30:00 677.5800170898438 678.5 676.1300048828125 677.8900146484375 8160082
354 2026-03-11 10:00:00 677.9000244140625 680.0800170898438 675.1950073242188 676.1500244140625 6569639
355 2026-03-11 10:30:00 676.1400146484375 678.3800048828125 675.2000122070312 678.0700073242188 3429360
356 2026-03-11 11:00:00 678.0800170898438 678.6400146484375 674.5900268554688 674.6699829101562 5050826
357 2026-03-11 11:30:00 674.6799926757812 675.2899780273438 673.6400146484375 674.2017822265625 3751820
358 2026-03-11 12:00:00 674.1900024414062 674.739990234375 673.510009765625 674.7050170898438 3828015
359 2026-03-11 12:30:00 674.7000122070312 676.3200073242188 674.6500244140625 676.2899780273438 2724664
360 2026-03-11 13:00:00 676.239990234375 676.77001953125 675.3300170898438 676.4600219726562 2873175
361 2026-03-11 13:30:00 676.469970703125 676.639892578125 673.3400268554688 674.989990234375 4988527
362 2026-03-11 14:00:00 674.989990234375 675.510009765625 673.7000122070312 675.27001953125 3592706
363 2026-03-11 14:30:00 675.27001953125 675.7899780273438 674.760009765625 675.25 4085110
364 2026-03-11 15:00:00 675.260009765625 675.469970703125 674.3200073242188 674.9749755859375 3957014
365 2026-03-11 15:30:00 674.989990234375 676.5999755859375 674.75 676.3300170898438 7671647
366 2026-03-12 09:30:00 671.1599731445312 671.6500244140625 668.1829833984375 668.8699951171875 13123121
367 2026-03-12 10:00:00 668.8699951171875 671.2000122070312 668.0499877929688 668.0599975585938 7337376
368 2026-03-12 10:30:00 668.0599975585938 670.5499877929688 667.2999877929688 668.5 7850132
369 2026-03-12 11:00:00 668.510009765625 668.7999877929688 666.9199829101562 668.1699829101562 5772318
370 2026-03-12 11:30:00 668.1699829101562 670.3099975585938 667.239990234375 669.2999267578125 8016798
371 2026-03-12 12:00:00 669.3099975585938 671.3099975585938 667.6699829101562 668.8400268554688 6836941
372 2026-03-12 12:30:00 668.77001953125 670.2899780273438 668.0399780273438 668.8400268554688 4544431
373 2026-03-12 13:00:00 668.8300170898438 670.3098754882812 667.989990234375 668.5599975585938 4077906
374 2026-03-12 13:30:00 668.5599975585938 669.75 667.2700805664062 668.1600952148438 5243231
375 2026-03-12 14:00:00 668.1699829101562 669.219970703125 667.6901245117188 668.1900024414062 4572965
376 2026-03-12 14:30:00 668.1799926757812 668.47998046875 667.2000122070312 667.3289794921875 5721278
377 2026-03-12 15:00:00 667.3200073242188 668.3900146484375 666.8699951171875 667.97998046875 7304760
378 2026-03-12 15:30:00 667.989990234375 668.6500244140625 665.905029296875 666.0399780273438 12771977
379 2026-03-13 09:30:00 669.27001953125 672.3350219726562 668.9600219726562 670.530029296875 12011325
380 2026-03-13 10:00:00 670.5399780273438 671.25 667.72998046875 668.840087890625 6206634
381 2026-03-13 10:30:00 668.8699951171875 670.22998046875 664.989990234375 665.3300170898438 7003488
382 2026-03-13 11:00:00 665.3400268554688 666.9400024414062 664.4299926757812 664.5900268554688 6813713
383 2026-03-13 11:30:00 664.5700073242188 665.3300170898438 663.0999755859375 663.3800048828125 6424291
384 2026-03-13 12:00:00 663.3599853515625 664.9650268554688 663.3400268554688 664.3300170898438 4488838
385 2026-03-13 12:30:00 664.3200073242188 665.22998046875 663.135009765625 663.3350219726562 4985126
386 2026-03-13 13:00:00 663.3200073242188 665.0800170898438 663.1300048828125 664.3699951171875 3772410
387 2026-03-13 13:30:00 664.3499755859375 664.52001953125 662.0800170898438 662.6300048828125 5456912
388 2026-03-13 14:00:00 662.6400146484375 663.72998046875 662.4500122070312 663.3400268554688 3983028
389 2026-03-13 14:30:00 663.3300170898438 664.8099975585938 663.0999755859375 664.1799926757812 3489301
390 2026-03-13 15:00:00 664.1799926757812 664.2000122070312 662.1599731445312 662.3699951171875 6013854
391 2026-03-13 15:30:00 662.3699951171875 663.0 661.3599853515625 662.2999877929688 11375885
392 2026-03-16 09:30:00 668.3800048828125 670.9000244140625 667.7000122070312 670.7999877929688 11920187
393 2026-03-16 10:00:00 670.7999877929688 671.4000244140625 669.989990234375 670.27001953125 5814198
394 2026-03-16 10:30:00 670.27001953125 670.3499755859375 667.5750122070312 669.8300170898438 6631428
395 2026-03-16 11:00:00 669.8400268554688 670.3200073242188 668.6199951171875 670.0599975585938 3835303
396 2026-03-16 11:30:00 670.0399780273438 670.6400146484375 668.4400024414062 668.469970703125 3881241
397 2026-03-16 12:00:00 668.469970703125 669.1900024414062 667.1500244140625 667.4500122070312 7640716
398 2026-03-16 12:30:00 667.4199829101562 669.073974609375 667.1199951171875 668.4249877929688 2896652
399 2026-03-16 13:00:00 668.4299926757812 669.75 668.406005859375 669.2000122070312 3136895
400 2026-03-16 13:30:00 669.2000122070312 669.5599975585938 667.97998046875 668.7550048828125 2786763
401 2026-03-16 14:00:00 668.760009765625 670.0800170898438 668.6599731445312 669.7999877929688 5673034
402 2026-03-16 14:30:00 669.7999877929688 670.3099975585938 669.489990234375 669.60498046875 3808006
403 2026-03-16 15:00:00 669.60498046875 672.0700073242188 668.6900024414062 669.27001953125 7795060
404 2026-03-16 15:30:00 669.27001953125 669.4299926757812 668.4199829101562 668.9600219726562 10497369
405 2026-03-17 09:30:00 672.3900146484375 674.4299926757812 671.9099731445312 673.8900146484375 9276301
406 2026-03-17 10:00:00 673.8800048828125 673.9099731445312 671.5599975585938 672.5599975585938 6014062
407 2026-03-17 10:30:00 672.5700073242188 673.469970703125 672.1400146484375 673.030029296875 3356916
408 2026-03-17 11:00:00 673.02001953125 673.27001953125 671.5999755859375 671.635009765625 4498373
409 2026-03-17 11:30:00 671.6300048828125 672.1900024414062 670.2999877929688 671.010009765625 5317857
410 2026-03-17 12:00:00 671.02001953125 671.5900268554688 669.7000122070312 671.2100219726562 4547148
411 2026-03-17 12:30:00 671.2000122070312 671.6400146484375 670.719970703125 670.9500122070312 2581648
412 2026-03-17 13:00:00 670.9400024414062 671.9400024414062 670.9299926757812 671.530029296875 2553434
413 2026-03-17 13:30:00 671.530029296875 671.9749755859375 671.030029296875 671.7000122070312 2665042
414 2026-03-17 14:00:00 671.7000122070312 672.2999877929688 670.8599853515625 670.9000244140625 5580685
415 2026-03-17 14:30:00 670.9000244140625 671.4400024414062 670.739990234375 671.0899047851562 3439269
416 2026-03-17 15:00:00 671.0900268554688 671.60498046875 670.5900268554688 671.0399780273438 4892325
417 2026-03-17 15:30:00 671.0399780273438 671.280029296875 670.469970703125 670.72998046875 10144142
418 2026-03-18 09:30:00 668.3599853515625 669.719970703125 667.8800048828125 669.280029296875 11319846
419 2026-03-18 10:00:00 669.280029296875 669.4099731445312 665.719970703125 665.9600219726562 5097139
420 2026-03-18 10:30:00 665.969970703125 667.155029296875 665.6599731445312 667.1400146484375 4118025
421 2026-03-18 11:00:00 667.1300048828125 668.0 666.4000244140625 666.9342041015625 3556343
422 2026-03-18 11:30:00 666.9099731445312 667.6199951171875 666.6699829101562 667.0399780273438 2637483
423 2026-03-18 12:00:00 667.030029296875 667.5800170898438 666.4918212890625 667.0700073242188 2075487
424 2026-03-18 12:30:00 667.0700073242188 667.2960205078125 665.8300170898438 666.5499877929688 2369646
425 2026-03-18 13:00:00 666.5399780273438 666.6300048828125 665.4299926757812 665.8400268554688 3112601
426 2026-03-18 13:30:00 665.8400268554688 666.8699951171875 665.2899780273438 666.719970703125 3091569
427 2026-03-18 14:00:00 666.7100219726562 667.4400024414062 665.3800048828125 666.1199951171875 4111070
428 2026-03-18 14:30:00 666.1099853515625 666.2999877929688 663.3200073242188 663.4099731445312 6801749
429 2026-03-18 15:00:00 663.4000244140625 664.719970703125 662.6799926757812 662.9099731445312 7921701
430 2026-03-18 15:30:00 662.9000244140625 663.2191772460938 661.1900024414062 661.5700073242188 14238601
431 2026-03-19 09:30:00 656.969970703125 658.9600219726562 655.1699829101562 658.6799926757812 17186363
432 2026-03-19 10:00:00 658.6599731445312 659.469970703125 656.0399780273438 656.3300170898438 6424002
433 2026-03-19 10:30:00 656.3400268554688 659.0640258789062 656.260009765625 657.7985229492188 5383915
434 2026-03-19 11:00:00 657.7899780273438 659.7100219726562 657.2100219726562 657.719970703125 6792002
435 2026-03-19 11:30:00 657.719970703125 657.9500122070312 656.1799926757812 657.9249877929688 6430177
436 2026-03-19 12:00:00 657.9099731445312 659.0399780273438 657.1199951171875 657.9500122070312 5450047
437 2026-03-19 12:30:00 657.97998046875 658.7100219726562 656.6099853515625 657.1680297851562 4799429
438 2026-03-19 13:00:00 657.1599731445312 657.5499877929688 655.6099853515625 657.1199951171875 4594504
439 2026-03-19 13:30:00 657.0999755859375 657.8800048828125 656.1599731445312 657.239990234375 3777320
440 2026-03-19 14:00:00 657.22998046875 658.6199951171875 656.6300048828125 657.1749877929688 5363917
441 2026-03-19 14:30:00 657.1799926757812 658.8099975585938 656.3200073242188 658.4500122070312 5554811
442 2026-03-19 15:00:00 658.4400024414062 662.97998046875 658.3900146484375 660.25 16369754
443 2026-03-19 15:30:00 660.239990234375 660.739990234375 658.97998046875 659.719970703125 11501098
444 2026-03-20 09:30:00 656.510009765625 656.6900024414062 653.0 653.1500244140625 13932173
445 2026-03-20 10:00:00 653.1699829101562 653.8110961914062 651.47998046875 651.8599853515625 7781700
446 2026-03-20 10:30:00 651.8599853515625 653.5 651.2000122070312 652.1199951171875 17849813
447 2026-03-20 11:00:00 652.1099853515625 654.1599731445312 651.5 651.9199829101562 6443642
448 2026-03-20 11:30:00 651.9400024414062 653.260009765625 651.72998046875 652.9450073242188 8483933
449 2026-03-20 12:00:00 652.969970703125 653.989990234375 652.780029296875 653.0999755859375 6873566
450 2026-03-20 12:30:00 653.0999755859375 653.4000244140625 651.9500122070312 652.77001953125 4407474
451 2026-03-20 13:00:00 652.760009765625 653.25 651.75 652.1599731445312 4777130
452 2026-03-20 13:30:00 652.1900024414062 652.3699951171875 650.5499877929688 651.4000244140625 5026757
453 2026-03-20 14:00:00 651.4099731445312 651.6950073242188 648.6599731445312 649.4299926757812 10109481
454 2026-03-20 14:30:00 649.4299926757812 649.655029296875 647.6400146484375 648.0800170898438 9537411
455 2026-03-20 15:00:00 648.0709838867188 648.64501953125 646.780029296875 647.1199951171875 11826121
456 2026-03-20 15:30:00 647.1300048828125 648.52001953125 644.719970703125 648.510009765625 27843185
457 2026-03-23 09:30:00 658.0700073242188 660.25 656.0700073242188 660.219970703125 28302521
458 2026-03-23 10:00:00 660.2000122070312 662.27001953125 658.5800170898438 660.989990234375 10460585
459 2026-03-23 10:30:00 660.97998046875 661.7999877929688 659.9500122070312 661.0499877929688 5831335
460 2026-03-23 11:00:00 661.0700073242188 662.614990234375 660.7000122070312 660.8900146484375 6990174
461 2026-03-23 11:30:00 660.9000244140625 661.3300170898438 655.8300170898438 656.010009765625 11768905
462 2026-03-23 12:00:00 655.989990234375 657.6300048828125 653.9400024414062 654.0900268554688 7951586
463 2026-03-23 12:30:00 654.1088256835938 656.739990234375 653.989990234375 655.5900268554688 5462108
464 2026-03-23 13:00:00 655.5800170898438 656.25 654.3099975585938 655.280029296875 3643061
465 2026-03-23 13:30:00 655.280029296875 658.25 655.260009765625 658.0599975585938 5759300
466 2026-03-23 14:00:00 658.0900268554688 658.3099975585938 656.530029296875 657.3900146484375 5101423
467 2026-03-23 14:30:00 657.4099731445312 659.6500244140625 657.4099731445312 658.9500122070312 6694821
468 2026-03-23 15:00:00 658.9500122070312 659.02001953125 657.030029296875 657.3300170898438 4990915
469 2026-03-23 15:30:00 657.3200073242188 658.3499755859375 655.3699951171875 655.3699951171875 13898637
470 2026-03-24 09:30:00 651.3200073242188 653.0399780273438 649.8800048828125 651.47998046875 10772865
471 2026-03-24 10:00:00 651.47998046875 653.585693359375 651.2261962890625 652.489990234375 6384275
472 2026-03-24 10:30:00 652.510009765625 655.9400024414062 651.219970703125 653.9400024414062 9976408
473 2026-03-24 11:00:00 653.97998046875 656.8300170898438 653.9199829101562 656.0399780273438 6092978
474 2026-03-24 11:30:00 656.0599975585938 657.030029296875 654.9099731445312 655.0399780273438 3672450
475 2026-03-24 12:00:00 655.0499877929688 656.25 654.2999877929688 655.9600219726562 4405909
476 2026-03-24 12:30:00 655.969970703125 656.1199951171875 654.0399780273438 655.1500244140625 3131337
477 2026-03-24 13:00:00 655.1300048828125 655.3900146484375 652.5499877929688 653.405029296875 5179672
478 2026-03-24 13:30:00 653.4000244140625 654.3400268554688 652.3800048828125 654.2999877929688 5046434
479 2026-03-24 14:00:00 654.2999877929688 654.97998046875 653.2000122070312 654.5900268554688 5000888
480 2026-03-24 14:30:00 654.5900268554688 656.5900268554688 654.280029296875 654.8599853515625 5716834
481 2026-03-24 15:00:00 654.8300170898438 655.6900024414062 654.3099975585938 654.4500122070312 4244818
482 2026-03-24 15:30:00 654.4500122070312 654.7750244140625 652.5599975585938 653.2000122070312 10043583
483 2026-03-25 09:30:00 658.6699829101562 660.8900146484375 656.4199829101562 656.489990234375 14170187
484 2026-03-25 10:00:00 656.5599975585938 660.5 655.6699829101562 658.1400146484375 8706025
485 2026-03-25 10:30:00 658.1599731445312 659.25 657.6500244140625 658.6900024414062 5082709
486 2026-03-25 11:00:00 658.6900024414062 659.0078125 654.239990234375 657.8400268554688 9494826
487 2026-03-25 11:30:00 657.8499755859375 658.239990234375 656.1199951171875 657.0 5217916
488 2026-03-25 12:00:00 657.0 658.0700073242188 656.010009765625 657.5 4334734
489 2026-03-25 12:30:00 657.4600219726562 658.3150024414062 657.0599975585938 657.75 4652674
490 2026-03-25 13:00:00 657.75 658.3200073242188 656.9149780273438 658.02001953125 4083255
491 2026-03-25 13:30:00 658.0399780273438 659.3800048828125 657.3300170898438 657.9000244140625 5590359
492 2026-03-25 14:00:00 657.8900146484375 658.510009765625 657.4099731445312 657.5150146484375 3271887
493 2026-03-25 14:30:00 657.510009765625 657.8099975585938 655.6400146484375 656.5 5186185
494 2026-03-25 15:00:00 656.489990234375 657.3201293945312 655.5800170898438 656.510009765625 6317455
495 2026-03-25 15:30:00 656.510009765625 657.0599975585938 655.7550048828125 656.739990234375 9045207
496 2026-03-26 09:30:00 652.0599975585938 654.6300048828125 650.72998046875 654.469970703125 13926765
497 2026-03-26 10:00:00 654.469970703125 654.8400268554688 653.364990234375 653.6199951171875 5285650
498 2026-03-26 10:30:00 653.6199951171875 654.8499755859375 650.8200073242188 652.2100219726562 9302797
499 2026-03-26 11:00:00 652.219970703125 652.9099731445312 651.5800170898438 651.7100219726562 5560880
500 2026-03-26 11:30:00 651.7100219726562 652.3499755859375 650.8699951171875 651.52001953125 4259219
501 2026-03-26 12:00:00 651.489990234375 651.6900024414062 649.2899780273438 649.469970703125 5418585
502 2026-03-26 12:30:00 649.4912719726562 649.719970703125 648.8900146484375 649.6400146484375 3519101
503 2026-03-26 13:00:00 649.6400146484375 650.0999755859375 647.97998046875 648.6199951171875 3483274
504 2026-03-26 13:30:00 648.6199951171875 649.0599975585938 646.4400024414062 646.6699829101562 5297883
505 2026-03-26 14:00:00 646.655029296875 647.5700073242188 646.489990234375 646.5999755859375 5021873
506 2026-03-26 14:30:00 646.6099853515625 647.6500244140625 646.5800170898438 647.4299926757812 5489198
507 2026-03-26 15:00:00 647.4400024414062 647.52001953125 645.8499755859375 646.2100219726562 6513076
508 2026-03-26 15:30:00 646.2100219726562 646.27001953125 644.8200073242188 645.10498046875 13326642
509 2026-03-27 09:30:00 642.5 642.6599731445312 638.593994140625 639.0499877929688 12919258
510 2026-03-27 10:00:00 639.030029296875 640.7999877929688 638.6599731445312 639.0999755859375 6595156
511 2026-03-27 10:30:00 639.0999755859375 640.5880126953125 638.2999877929688 640.1900024414062 4570242
512 2026-03-27 11:00:00 640.1900024414062 641.489990234375 638.9401245117188 640.9600219726562 6539241
513 2026-03-27 11:30:00 640.9600219726562 642.1400146484375 640.1099853515625 640.280029296875 4787677
514 2026-03-27 12:00:00 640.2999877929688 640.4299926757812 638.5800170898438 638.8900146484375 4244393
515 2026-03-27 12:30:00 638.8800048828125 639.6300048828125 638.0800170898438 638.7000122070312 4548141
516 2026-03-27 13:00:00 638.7000122070312 639.9299926757812 636.5900268554688 636.6199951171875 6145318
517 2026-03-27 13:30:00 636.6199951171875 637.25 635.9400024414062 636.1300048828125 4731959
518 2026-03-27 14:00:00 636.1397705078125 636.3400268554688 635.1300048828125 635.77001953125 5909420
519 2026-03-27 14:30:00 635.7650146484375 636.7249755859375 635.2000122070312 635.3800048828125 5524934
520 2026-03-27 15:00:00 635.3800048828125 635.6799926757812 634.52001953125 634.8590087890625 7965009
521 2026-03-27 15:30:00 634.8599853515625 635.3499755859375 633.10498046875 634.0800170898438 16949297

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

View File

@@ -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:

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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", [])

File diff suppressed because it is too large Load Diff

View File

@@ -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",
]

View 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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -0,0 +1 @@
"""Tests for ``openbb_technical.multi``."""

View 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"]

View 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")

View 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"

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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 == []

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View 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

View File

@@ -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

View 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} }"
)

View File

@@ -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!")

View File

@@ -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

File diff suppressed because it is too large Load Diff