mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-01 15:01:18 +00:00
* 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 <>
193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
"""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",
|
|
]
|