mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +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 <>
258 lines
8.0 KiB
Python
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",
|
|
]
|