Files
OpenBB/openbb_platform/extensions/technical/openbb_technical/signals/patterns.py
Danglewood d3d33a219b [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 <>
2026-05-29 10:48:51 +03:00

258 lines
8.0 KiB
Python

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