feat: implement dual-pipeline signal engine service
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
New service at services/signal_engine/ implementing concurrent heuristic (deterministic scoring) and probabilistic (Bayesian inference) pipelines that evaluate technical signals across 6 timeframes (M30-M) and produce independent BUY/WATCH/SKIP verdicts per ticker per evaluation tick. Components: - Input Normalizer: multi-source data assembly with sentinel fallbacks - Signal Library: Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave - Multi-Timeframe Confluence Engine: weighted scoring with D/W/M anchors - Hard Filter Engine: macro_bias, valuation, earnings proximity gating - Heuristic Pipeline: S_total scoring with confidence-gated verdicts - Probabilistic Pipeline: Bayesian log-odds with regime priors, entropy gating, EV_R calculation, and signal correlation penalty - Exit Engine: stop-loss, targets, trailing ATR-based stops - Delta Analyzer: pipeline agreement tracking with rolling Redis metrics - Output Formatter: SignalOutput contract + Recommendation schema mapping - Worker orchestrator: concurrent pipelines with failure isolation - Main entry point: queue polling with fail-safe config loading Infrastructure: - Migration 039: signal_engine_outputs table with 3 indexes - Helm chart: signalEngine service entry (processing tier) - Redis key: QUEUE_SIGNAL_ENGINE constant Tests: 390 tests (unit + property-based) covering all components Config: dual_pipeline_enabled=false by default (safe rollout)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Signal Library - technical signal evaluators (Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Base protocol and common helpers for signal evaluators.
|
||||
|
||||
Defines the ``SignalEvaluator`` protocol that every signal in the Signal
|
||||
Library must satisfy, plus shared utility functions for swing detection,
|
||||
lookback validation, and simple moving average computation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalResult
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal evaluator protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SignalEvaluator(Protocol):
|
||||
"""Protocol for all signal evaluators in the Signal Library.
|
||||
|
||||
Each evaluator receives a list of OHLCV bars for a single timeframe
|
||||
and returns a ``SignalResult`` when the signal triggers, or ``None``
|
||||
when insufficient data is available or the signal does not fire.
|
||||
"""
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate a signal on a single timeframe's bar data.
|
||||
|
||||
Returns ``None`` when insufficient data is available.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_swing_high(
|
||||
bars: list[OHLCVBar],
|
||||
lookback: int,
|
||||
) -> tuple[int, float] | None:
|
||||
"""Find the highest high in the last *lookback* bars.
|
||||
|
||||
Args:
|
||||
bars: OHLCV bar series (oldest-first).
|
||||
lookback: Number of recent bars to search.
|
||||
|
||||
Returns:
|
||||
``(index, price)`` of the bar with the highest high within the
|
||||
lookback window, or ``None`` if *bars* has fewer than *lookback*
|
||||
entries.
|
||||
"""
|
||||
if len(bars) < lookback or lookback <= 0:
|
||||
return None
|
||||
|
||||
window = bars[-lookback:]
|
||||
offset = len(bars) - lookback
|
||||
|
||||
best_idx = 0
|
||||
best_price = window[0].high
|
||||
for i, bar in enumerate(window):
|
||||
if bar.high >= best_price:
|
||||
best_idx = i
|
||||
best_price = bar.high
|
||||
|
||||
return (offset + best_idx, best_price)
|
||||
|
||||
|
||||
def find_swing_low(
|
||||
bars: list[OHLCVBar],
|
||||
lookback: int,
|
||||
) -> tuple[int, float] | None:
|
||||
"""Find the lowest low in the last *lookback* bars.
|
||||
|
||||
Args:
|
||||
bars: OHLCV bar series (oldest-first).
|
||||
lookback: Number of recent bars to search.
|
||||
|
||||
Returns:
|
||||
``(index, price)`` of the bar with the lowest low within the
|
||||
lookback window, or ``None`` if *bars* has fewer than *lookback*
|
||||
entries.
|
||||
"""
|
||||
if len(bars) < lookback or lookback <= 0:
|
||||
return None
|
||||
|
||||
window = bars[-lookback:]
|
||||
offset = len(bars) - lookback
|
||||
|
||||
best_idx = 0
|
||||
best_price = window[0].low
|
||||
for i, bar in enumerate(window):
|
||||
if bar.low <= best_price:
|
||||
best_idx = i
|
||||
best_price = bar.low
|
||||
|
||||
return (offset + best_idx, best_price)
|
||||
|
||||
|
||||
def validate_lookback(bars: list[OHLCVBar], min_bars: int) -> bool:
|
||||
"""Return ``True`` if *bars* contains at least *min_bars* entries."""
|
||||
return len(bars) >= min_bars
|
||||
|
||||
|
||||
def compute_sma(bars: list[OHLCVBar], period: int) -> float | None:
|
||||
"""Compute the simple moving average of close prices over the last *period* bars.
|
||||
|
||||
Args:
|
||||
bars: OHLCV bar series (oldest-first).
|
||||
period: Number of recent bars to average.
|
||||
|
||||
Returns:
|
||||
The arithmetic mean of the last *period* close prices, or ``None``
|
||||
if *bars* has fewer than *period* entries or *period* is not
|
||||
positive.
|
||||
"""
|
||||
if period <= 0 or len(bars) < period:
|
||||
return None
|
||||
|
||||
total = sum(bar.close for bar in bars[-period:])
|
||||
return total / period
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Cup & Handle pattern signal evaluator.
|
||||
|
||||
Detects the Cup & Handle chart pattern — a bullish continuation pattern
|
||||
consisting of a U-shaped price recovery (the cup) followed by a small
|
||||
consolidation pullback (the handle).
|
||||
|
||||
Pattern detection algorithm:
|
||||
1. Find the left rim (local high in the first third of bars).
|
||||
2. Find the cup bottom (lowest low between left rim and right rim area).
|
||||
3. Find the right rim (local high in the last third of bars, near left rim price).
|
||||
4. Identify the handle as a small pullback after the right rim (last few bars).
|
||||
|
||||
Pattern completeness scoring:
|
||||
- Cup depth: ``(left_rim - bottom) / left_rim`` — valid range 12–33%.
|
||||
- Symmetry: how close left_rim and right_rim prices are (within 5% = perfect).
|
||||
- Handle: small pullback (< 50% of cup depth) after right rim.
|
||||
|
||||
The signal is always BULLISH (cup & handle is a bullish continuation pattern).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||
from services.signal_engine.signals.base import validate_lookback
|
||||
|
||||
# Default minimum number of bars required for cup & handle detection
|
||||
DEFAULT_MIN_BARS: int = 30
|
||||
|
||||
# Cup depth valid range (as fraction of left rim price)
|
||||
_CUP_DEPTH_MIN: float = 0.12 # 12%
|
||||
_CUP_DEPTH_MAX: float = 0.33 # 33%
|
||||
|
||||
# Symmetry: maximum allowed difference between left and right rim prices
|
||||
# as a fraction of left rim price for "perfect" symmetry
|
||||
_SYMMETRY_PERFECT_PCT: float = 0.05 # 5%
|
||||
|
||||
# Handle: maximum pullback as fraction of cup depth
|
||||
_HANDLE_MAX_RETRACE: float = 0.50 # 50% of cup depth
|
||||
|
||||
# Handle lookback: number of bars at the end to check for handle
|
||||
_HANDLE_LOOKBACK_FRACTION: float = 0.15 # last 15% of bars
|
||||
|
||||
# Confidence multiplier
|
||||
_CONFIDENCE_MULTIPLIER: float = 0.90
|
||||
|
||||
|
||||
class CupHandleEvaluator:
|
||||
"""Cup & Handle pattern signal evaluator.
|
||||
|
||||
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||
protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
min_bars:
|
||||
Minimum number of OHLCV bars required before the evaluator will
|
||||
produce a signal. Defaults to ``30``.
|
||||
"""
|
||||
|
||||
def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None:
|
||||
self.min_bars = min_bars
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (SignalEvaluator protocol)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate Cup & Handle pattern on *bars* for *timeframe*.
|
||||
|
||||
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||
or when no valid cup & handle pattern is detected.
|
||||
"""
|
||||
if not validate_lookback(bars, self.min_bars):
|
||||
return None
|
||||
|
||||
n = len(bars)
|
||||
|
||||
# --- Step 1: Find the left rim (highest high in first third) ---
|
||||
first_third_end = n // 3
|
||||
if first_third_end < 1:
|
||||
return None
|
||||
|
||||
left_rim_idx = 0
|
||||
left_rim_price = bars[0].high
|
||||
for i in range(1, first_third_end):
|
||||
if bars[i].high > left_rim_price:
|
||||
left_rim_idx = i
|
||||
left_rim_price = bars[i].high
|
||||
|
||||
if left_rim_price <= 0:
|
||||
return None
|
||||
|
||||
# --- Step 2: Find the right rim (highest high in last third) ---
|
||||
last_third_start = n - (n // 3)
|
||||
if last_third_start >= n:
|
||||
return None
|
||||
|
||||
right_rim_idx = last_third_start
|
||||
right_rim_price = bars[last_third_start].high
|
||||
for i in range(last_third_start + 1, n):
|
||||
if bars[i].high > right_rim_price:
|
||||
right_rim_idx = i
|
||||
right_rim_price = bars[i].high
|
||||
|
||||
# --- Step 3: Find the cup bottom (lowest low between rims) ---
|
||||
search_start = left_rim_idx + 1
|
||||
search_end = right_rim_idx
|
||||
if search_start >= search_end:
|
||||
return None
|
||||
|
||||
bottom_idx = search_start
|
||||
bottom_price = bars[search_start].low
|
||||
for i in range(search_start + 1, search_end):
|
||||
if bars[i].low < bottom_price:
|
||||
bottom_idx = i
|
||||
bottom_price = bars[i].low
|
||||
|
||||
# --- Step 4: Validate cup depth ---
|
||||
cup_depth = left_rim_price - bottom_price
|
||||
if cup_depth <= 0:
|
||||
return None
|
||||
|
||||
cup_depth_pct = cup_depth / left_rim_price
|
||||
if cup_depth_pct < _CUP_DEPTH_MIN or cup_depth_pct > _CUP_DEPTH_MAX:
|
||||
return None
|
||||
|
||||
# --- Step 5: Score symmetry (left rim vs right rim) ---
|
||||
rim_diff_pct = abs(left_rim_price - right_rim_price) / left_rim_price
|
||||
if rim_diff_pct <= _SYMMETRY_PERFECT_PCT:
|
||||
symmetry_score = 1.0
|
||||
else:
|
||||
# Linear decay from 1.0 at 5% to 0.0 at 20%
|
||||
max_diff = 0.20
|
||||
symmetry_score = max(0.0, 1.0 - (rim_diff_pct - _SYMMETRY_PERFECT_PCT) / (max_diff - _SYMMETRY_PERFECT_PCT))
|
||||
|
||||
# Right rim must be at least close to left rim (within 20%)
|
||||
if symmetry_score <= 0.0:
|
||||
return None
|
||||
|
||||
# --- Step 6: Detect and score the handle ---
|
||||
handle_lookback = max(2, int(n * _HANDLE_LOOKBACK_FRACTION))
|
||||
handle_bars = bars[-handle_lookback:]
|
||||
|
||||
# Handle is a small pullback from the right rim
|
||||
handle_low = min(b.low for b in handle_bars)
|
||||
handle_depth = right_rim_price - handle_low
|
||||
|
||||
if cup_depth <= 0:
|
||||
return None
|
||||
|
||||
handle_retrace = handle_depth / cup_depth
|
||||
|
||||
if handle_retrace > _HANDLE_MAX_RETRACE:
|
||||
# Handle is too deep — not a valid cup & handle
|
||||
return None
|
||||
|
||||
# Handle score: 1.0 when handle is very shallow, decreasing as it deepens
|
||||
if handle_retrace <= 0:
|
||||
handle_score = 1.0
|
||||
else:
|
||||
handle_score = 1.0 - (handle_retrace / _HANDLE_MAX_RETRACE)
|
||||
|
||||
# --- Step 7: Cup depth quality score ---
|
||||
# Ideal cup depth is around 20-25% — score peaks in the middle of valid range
|
||||
ideal_depth = (_CUP_DEPTH_MIN + _CUP_DEPTH_MAX) / 2.0 # 0.225
|
||||
depth_deviation = abs(cup_depth_pct - ideal_depth) / ((_CUP_DEPTH_MAX - _CUP_DEPTH_MIN) / 2.0)
|
||||
depth_score = max(0.0, 1.0 - depth_deviation)
|
||||
|
||||
# --- Step 8: Compute overall completeness ---
|
||||
completeness = (
|
||||
0.35 * symmetry_score
|
||||
+ 0.35 * depth_score
|
||||
+ 0.30 * handle_score
|
||||
)
|
||||
completeness = max(0.0, min(1.0, completeness))
|
||||
|
||||
# --- Step 9: Build signal result ---
|
||||
strength = completeness
|
||||
confidence = completeness * _CONFIDENCE_MULTIPLIER
|
||||
|
||||
return SignalResult(
|
||||
signal_type="cup_handle",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=SignalDirection.BULLISH,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"left_rim": left_rim_price,
|
||||
"left_rim_idx": left_rim_idx,
|
||||
"right_rim": right_rim_price,
|
||||
"right_rim_idx": right_rim_idx,
|
||||
"bottom": bottom_price,
|
||||
"bottom_idx": bottom_idx,
|
||||
"cup_depth_pct": round(cup_depth_pct, 4),
|
||||
"handle_depth": round(handle_depth, 4),
|
||||
"handle_retrace_pct": round(handle_retrace, 4),
|
||||
"symmetry_score": round(symmetry_score, 4),
|
||||
"depth_score": round(depth_score, 4),
|
||||
"handle_score": round(handle_score, 4),
|
||||
"completeness": round(completeness, 4),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Elliott Wave signal evaluator.
|
||||
|
||||
Detects Elliott Wave patterns — impulse waves (5-wave structure) and
|
||||
corrective waves (3-wave structure) — using a simplified zigzag pivot
|
||||
filter. Produces a signal with the current wave position and projected
|
||||
direction.
|
||||
|
||||
Wave detection algorithm (simplified):
|
||||
1. Find significant pivot points (local highs and lows) using a zigzag
|
||||
filter that identifies reversals of at least X% of the price range.
|
||||
2. Count alternating pivots to identify wave structure.
|
||||
3. Five alternating pivots = impulse wave (bullish if trending up,
|
||||
bearish if trending down).
|
||||
4. Three alternating pivots after an impulse = corrective wave.
|
||||
|
||||
Signal logic:
|
||||
- Impulse wave 3 or 5: strong signal in the trend direction.
|
||||
- Corrective wave (A, B, C): signal in the opposite direction
|
||||
(anticipating next impulse).
|
||||
- Ambiguous wave count: return ``None``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||
from services.signal_engine.signals.base import validate_lookback
|
||||
|
||||
# Default minimum number of bars required for evaluation
|
||||
DEFAULT_MIN_BARS: int = 30
|
||||
|
||||
# Minimum zigzag reversal threshold as a fraction of the price range
|
||||
_DEFAULT_ZIGZAG_PCT: float = 0.05 # 5%
|
||||
|
||||
# Wave type labels
|
||||
WAVE_TYPE_IMPULSE: str = "impulse"
|
||||
WAVE_TYPE_CORRECTIVE: str = "corrective"
|
||||
|
||||
# Impulse wave positions (1-indexed)
|
||||
_IMPULSE_WAVE_COUNT: int = 5
|
||||
# Corrective wave positions
|
||||
_CORRECTIVE_WAVE_COUNT: int = 3
|
||||
|
||||
# Confidence multiplier for wave clarity
|
||||
_CONFIDENCE_MULTIPLIER: float = 0.85
|
||||
|
||||
|
||||
class ElliottWaveEvaluator:
|
||||
"""Elliott Wave pattern signal evaluator.
|
||||
|
||||
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||
protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
min_bars:
|
||||
Minimum number of OHLCV bars required before the evaluator will
|
||||
produce a signal. Defaults to ``30``.
|
||||
zigzag_pct:
|
||||
Minimum reversal threshold as a fraction of the overall price
|
||||
range for the zigzag filter. Defaults to ``0.05`` (5%).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_bars: int = DEFAULT_MIN_BARS,
|
||||
zigzag_pct: float = _DEFAULT_ZIGZAG_PCT,
|
||||
) -> None:
|
||||
self.min_bars = min_bars
|
||||
self.zigzag_pct = zigzag_pct
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (SignalEvaluator protocol)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate Elliott Wave pattern on *bars* for *timeframe*.
|
||||
|
||||
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||
when the market is flat (no price range), or when the wave count
|
||||
is ambiguous.
|
||||
"""
|
||||
if not validate_lookback(bars, self.min_bars):
|
||||
return None
|
||||
|
||||
# Compute overall price range for the zigzag threshold
|
||||
overall_high = max(b.high for b in bars)
|
||||
overall_low = min(b.low for b in bars)
|
||||
price_range = overall_high - overall_low
|
||||
|
||||
if price_range <= 0:
|
||||
return None # flat market
|
||||
|
||||
zigzag_threshold = price_range * self.zigzag_pct
|
||||
|
||||
# Find zigzag pivots
|
||||
pivots = _find_zigzag_pivots(bars, zigzag_threshold)
|
||||
|
||||
if len(pivots) < _CORRECTIVE_WAVE_COUNT:
|
||||
return None # not enough pivots for any wave structure
|
||||
|
||||
# Try to identify wave structure from the pivots
|
||||
wave_info = _classify_waves(pivots, price_range)
|
||||
|
||||
if wave_info is None:
|
||||
return None # ambiguous wave count
|
||||
|
||||
wave_type = wave_info["wave_type"]
|
||||
current_position = wave_info["current_position"]
|
||||
trend_up = wave_info["trend_up"]
|
||||
clarity = wave_info["clarity"]
|
||||
|
||||
# Determine direction and strength based on wave type and position
|
||||
direction: SignalDirection
|
||||
strength: float
|
||||
|
||||
if wave_type == WAVE_TYPE_IMPULSE:
|
||||
# Impulse wave: signal in the trend direction
|
||||
direction = SignalDirection.BULLISH if trend_up else SignalDirection.BEARISH
|
||||
# Waves 3 and 5 are the strongest signal points
|
||||
if current_position in (3, 5):
|
||||
strength = min(1.0, clarity * 1.0)
|
||||
else:
|
||||
strength = min(1.0, clarity * 0.6)
|
||||
else:
|
||||
# Corrective wave: signal opposite to the correction
|
||||
# (anticipating next impulse in the original trend direction)
|
||||
direction = SignalDirection.BULLISH if trend_up else SignalDirection.BEARISH
|
||||
strength = min(1.0, clarity * 0.7)
|
||||
|
||||
confidence = min(1.0, clarity * _CONFIDENCE_MULTIPLIER)
|
||||
|
||||
# Build pivot list for metadata (index, price, type)
|
||||
pivot_meta = [
|
||||
{"index": p["index"], "price": p["price"], "type": p["type"]}
|
||||
for p in pivots
|
||||
]
|
||||
|
||||
return SignalResult(
|
||||
signal_type="elliott_wave",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=direction,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"wave_count": len(pivots),
|
||||
"wave_type": wave_type,
|
||||
"current_wave_position": current_position,
|
||||
"trend_up": trend_up,
|
||||
"clarity": round(clarity, 4),
|
||||
"pivots": pivot_meta,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_zigzag_pivots(
|
||||
bars: list[OHLCVBar],
|
||||
threshold: float,
|
||||
) -> list[dict]:
|
||||
"""Find significant pivot points using a zigzag filter.
|
||||
|
||||
A pivot is a local high or low where the price reverses by at least
|
||||
*threshold* from the last confirmed pivot.
|
||||
|
||||
Returns a list of dicts with keys: ``index``, ``price``, ``type``
|
||||
(``"high"`` or ``"low"``).
|
||||
"""
|
||||
if len(bars) < 2:
|
||||
return []
|
||||
|
||||
pivots: list[dict] = []
|
||||
|
||||
# Seed with the first bar's high and low as candidates
|
||||
last_high_idx = 0
|
||||
last_high = bars[0].high
|
||||
last_low_idx = 0
|
||||
last_low = bars[0].low
|
||||
|
||||
# Direction: 1 = looking for a high (trending up), -1 = looking for a low
|
||||
# Start by determining initial direction from first two bars
|
||||
if bars[1].close >= bars[0].close:
|
||||
direction = 1 # trending up, looking for a high
|
||||
else:
|
||||
direction = -1 # trending down, looking for a low
|
||||
|
||||
for i in range(1, len(bars)):
|
||||
bar = bars[i]
|
||||
|
||||
if direction == 1:
|
||||
# Trending up — track the highest high
|
||||
if bar.high >= last_high:
|
||||
last_high = bar.high
|
||||
last_high_idx = i
|
||||
# Check for reversal: price dropped by threshold from the high
|
||||
if last_high - bar.low >= threshold:
|
||||
# Confirm the high as a pivot
|
||||
pivots.append({
|
||||
"index": last_high_idx,
|
||||
"price": last_high,
|
||||
"type": "high",
|
||||
})
|
||||
# Switch direction: now looking for a low
|
||||
direction = -1
|
||||
last_low = bar.low
|
||||
last_low_idx = i
|
||||
else:
|
||||
# Trending down — track the lowest low
|
||||
if bar.low <= last_low:
|
||||
last_low = bar.low
|
||||
last_low_idx = i
|
||||
# Check for reversal: price rose by threshold from the low
|
||||
if bar.high - last_low >= threshold:
|
||||
# Confirm the low as a pivot
|
||||
pivots.append({
|
||||
"index": last_low_idx,
|
||||
"price": last_low,
|
||||
"type": "low",
|
||||
})
|
||||
# Switch direction: now looking for a high
|
||||
direction = 1
|
||||
last_high = bar.high
|
||||
last_high_idx = i
|
||||
|
||||
# Add the final unconfirmed pivot (the current trend endpoint)
|
||||
if direction == 1 and (not pivots or pivots[-1]["type"] != "high"):
|
||||
pivots.append({
|
||||
"index": last_high_idx,
|
||||
"price": last_high,
|
||||
"type": "high",
|
||||
})
|
||||
elif direction == -1 and (not pivots or pivots[-1]["type"] != "low"):
|
||||
pivots.append({
|
||||
"index": last_low_idx,
|
||||
"price": last_low,
|
||||
"type": "low",
|
||||
})
|
||||
|
||||
return pivots
|
||||
|
||||
|
||||
def _classify_waves(
|
||||
pivots: list[dict],
|
||||
price_range: float,
|
||||
) -> dict | None:
|
||||
"""Classify the pivot sequence as impulse or corrective waves.
|
||||
|
||||
Returns a dict with ``wave_type``, ``current_position``, ``trend_up``,
|
||||
and ``clarity``, or ``None`` if the wave count is ambiguous.
|
||||
"""
|
||||
n = len(pivots)
|
||||
|
||||
if n < _CORRECTIVE_WAVE_COUNT:
|
||||
return None
|
||||
|
||||
# Determine overall trend from first to last pivot
|
||||
first_price = pivots[0]["price"]
|
||||
last_price = pivots[-1]["price"]
|
||||
trend_up = last_price > first_price
|
||||
|
||||
# Try impulse wave (5 pivots) first, then corrective (3 pivots)
|
||||
if n >= _IMPULSE_WAVE_COUNT:
|
||||
# Use the last 5 pivots for impulse wave detection
|
||||
impulse_pivots = pivots[-_IMPULSE_WAVE_COUNT:]
|
||||
impulse_result = _check_impulse(impulse_pivots, trend_up, price_range)
|
||||
if impulse_result is not None:
|
||||
return impulse_result
|
||||
|
||||
# Check if there's a corrective wave after an impulse
|
||||
# (need at least 5 + 3 = 8 pivots for impulse + corrective)
|
||||
if n >= _IMPULSE_WAVE_COUNT + _CORRECTIVE_WAVE_COUNT:
|
||||
# Check if the first 5 pivots form an impulse
|
||||
early_impulse = pivots[:_IMPULSE_WAVE_COUNT]
|
||||
early_result = _check_impulse(early_impulse, trend_up, price_range)
|
||||
if early_result is not None:
|
||||
# The remaining pivots may form a corrective wave
|
||||
corrective_pivots = pivots[_IMPULSE_WAVE_COUNT:_IMPULSE_WAVE_COUNT + _CORRECTIVE_WAVE_COUNT]
|
||||
corrective_result = _check_corrective(
|
||||
corrective_pivots, trend_up, price_range,
|
||||
)
|
||||
if corrective_result is not None:
|
||||
return corrective_result
|
||||
|
||||
# Try corrective wave (3 pivots) from the tail
|
||||
if n >= _CORRECTIVE_WAVE_COUNT:
|
||||
corrective_pivots = pivots[-_CORRECTIVE_WAVE_COUNT:]
|
||||
corrective_result = _check_corrective(
|
||||
corrective_pivots, trend_up, price_range,
|
||||
)
|
||||
if corrective_result is not None:
|
||||
return corrective_result
|
||||
|
||||
return None # ambiguous
|
||||
|
||||
|
||||
def _check_impulse(
|
||||
pivots: list[dict],
|
||||
trend_up: bool,
|
||||
price_range: float,
|
||||
) -> dict | None:
|
||||
"""Check if 5 pivots form a valid impulse wave.
|
||||
|
||||
For a bullish impulse (trend_up=True):
|
||||
- Wave 1 (low→high): price rises
|
||||
- Wave 2 (high→low): price falls but stays above wave 1 start
|
||||
- Wave 3 (low→high): price rises above wave 1 high (wave 3 is longest)
|
||||
- Wave 4 (high→low): price falls but stays above wave 1 high
|
||||
- Wave 5 (low→high): price rises to new high
|
||||
|
||||
For bearish impulse, the pattern is inverted.
|
||||
"""
|
||||
if len(pivots) != _IMPULSE_WAVE_COUNT:
|
||||
return None
|
||||
|
||||
prices = [p["price"] for p in pivots]
|
||||
|
||||
if trend_up:
|
||||
# Bullish impulse: alternating low-high-low-high-low or high-low-high-low-high
|
||||
# Check for generally ascending pattern with higher highs
|
||||
valid = _validate_bullish_impulse(prices)
|
||||
else:
|
||||
# Bearish impulse: generally descending pattern with lower lows
|
||||
valid = _validate_bearish_impulse(prices)
|
||||
|
||||
if not valid:
|
||||
return None
|
||||
|
||||
# Compute clarity: how clean the wave structure is
|
||||
clarity = _compute_impulse_clarity(prices, trend_up, price_range)
|
||||
|
||||
# Current position is wave 5 (the last wave in the impulse)
|
||||
return {
|
||||
"wave_type": WAVE_TYPE_IMPULSE,
|
||||
"current_position": 5,
|
||||
"trend_up": trend_up,
|
||||
"clarity": clarity,
|
||||
}
|
||||
|
||||
|
||||
def _validate_bullish_impulse(prices: list[float]) -> bool:
|
||||
"""Validate a 5-pivot sequence as a bullish impulse.
|
||||
|
||||
Simplified rules:
|
||||
- The overall trend is up (last > first).
|
||||
- Wave 3 (pivot 2 to pivot 3) should be the largest move or
|
||||
at least not the shortest.
|
||||
- Wave 2 should not retrace below wave 1 start.
|
||||
- Wave 4 should not overlap wave 1 end.
|
||||
"""
|
||||
if len(prices) != 5:
|
||||
return False
|
||||
|
||||
# Overall upward trend
|
||||
if prices[-1] <= prices[0]:
|
||||
return False
|
||||
|
||||
# Compute wave magnitudes
|
||||
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||
|
||||
# Wave 3 (index 2) should not be the shortest impulse wave
|
||||
# Impulse waves are waves 0, 2, 4 (odd-indexed moves in 0-based)
|
||||
impulse_waves = [waves[0], waves[2]]
|
||||
if len(waves) > 3:
|
||||
impulse_waves.append(waves[3])
|
||||
|
||||
# Wave 3 (waves[2]) should be significant
|
||||
if waves[2] < min(waves[0], waves[2]) * 0.5:
|
||||
return False
|
||||
|
||||
# The pattern should show alternating direction
|
||||
# Check that consecutive pivots alternate in direction
|
||||
for i in range(3):
|
||||
move_a = prices[i + 1] - prices[i]
|
||||
move_b = prices[i + 2] - prices[i + 1]
|
||||
# Consecutive moves should be in opposite directions
|
||||
if move_a * move_b >= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_bearish_impulse(prices: list[float]) -> bool:
|
||||
"""Validate a 5-pivot sequence as a bearish impulse.
|
||||
|
||||
Mirror of bullish validation with inverted price direction.
|
||||
"""
|
||||
if len(prices) != 5:
|
||||
return False
|
||||
|
||||
# Overall downward trend
|
||||
if prices[-1] >= prices[0]:
|
||||
return False
|
||||
|
||||
# Compute wave magnitudes
|
||||
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||
|
||||
# Wave 3 (waves[2]) should be significant
|
||||
if waves[2] < min(waves[0], waves[2]) * 0.5:
|
||||
return False
|
||||
|
||||
# Check alternating direction
|
||||
for i in range(3):
|
||||
move_a = prices[i + 1] - prices[i]
|
||||
move_b = prices[i + 2] - prices[i + 1]
|
||||
if move_a * move_b >= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _compute_impulse_clarity(
|
||||
prices: list[float],
|
||||
trend_up: bool,
|
||||
price_range: float,
|
||||
) -> float:
|
||||
"""Compute wave clarity for an impulse wave.
|
||||
|
||||
Clarity is based on:
|
||||
- How well the pivots alternate (already validated).
|
||||
- How proportional the wave magnitudes are.
|
||||
- How significant the waves are relative to the price range.
|
||||
"""
|
||||
if price_range <= 0:
|
||||
return 0.0
|
||||
|
||||
waves = [abs(prices[i + 1] - prices[i]) for i in range(4)]
|
||||
total_movement = sum(waves)
|
||||
|
||||
# Significance: total wave movement relative to price range
|
||||
significance = min(1.0, total_movement / (price_range * 2.0))
|
||||
|
||||
# Proportionality: wave 3 should be the largest or close to it
|
||||
max_wave = max(waves)
|
||||
if max_wave <= 0:
|
||||
return 0.0
|
||||
|
||||
wave3_ratio = waves[2] / max_wave # 1.0 if wave 3 is the largest
|
||||
|
||||
# Overall clarity
|
||||
clarity = 0.5 * significance + 0.5 * wave3_ratio
|
||||
return max(0.0, min(1.0, clarity))
|
||||
|
||||
|
||||
def _check_corrective(
|
||||
pivots: list[dict],
|
||||
trend_up: bool,
|
||||
price_range: float,
|
||||
) -> dict | None:
|
||||
"""Check if 3 pivots form a valid corrective wave (A-B-C).
|
||||
|
||||
A corrective wave moves against the main trend:
|
||||
- For a bullish main trend: corrective wave moves down (A down, B up, C down).
|
||||
- For a bearish main trend: corrective wave moves up (A up, B down, C up).
|
||||
"""
|
||||
if len(pivots) != _CORRECTIVE_WAVE_COUNT:
|
||||
return None
|
||||
|
||||
prices = [p["price"] for p in pivots]
|
||||
|
||||
# Check alternating direction
|
||||
move_a = prices[1] - prices[0]
|
||||
move_b = prices[2] - prices[1]
|
||||
|
||||
# Moves must be in opposite directions
|
||||
if move_a * move_b >= 0:
|
||||
return None
|
||||
|
||||
# For a bullish main trend, the corrective wave should move down overall
|
||||
if trend_up:
|
||||
if prices[2] >= prices[0]:
|
||||
return None # not a downward correction
|
||||
else:
|
||||
if prices[2] <= prices[0]:
|
||||
return None # not an upward correction
|
||||
|
||||
# Compute clarity
|
||||
waves = [abs(prices[1] - prices[0]), abs(prices[2] - prices[1])]
|
||||
total_movement = sum(waves)
|
||||
|
||||
if price_range <= 0:
|
||||
return 0.0
|
||||
|
||||
significance = min(1.0, total_movement / price_range)
|
||||
clarity = significance * 0.8 # corrective waves are inherently less clear
|
||||
|
||||
# Current position is wave C (the last wave in the correction)
|
||||
return {
|
||||
"wave_type": WAVE_TYPE_CORRECTIVE,
|
||||
"current_position": 3, # wave C
|
||||
"trend_up": trend_up,
|
||||
"clarity": max(0.0, min(1.0, clarity)),
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Fibonacci retracement signal evaluator.
|
||||
|
||||
Computes retracement levels using ``L(r) = SH - r * (SH - SL)`` for the
|
||||
standard ratios [0.236, 0.382, 0.5, 0.618, 0.786] and produces a signal
|
||||
based on the proximity of the current price to the nearest level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||
from services.signal_engine.signals.base import (
|
||||
find_swing_high,
|
||||
find_swing_low,
|
||||
validate_lookback,
|
||||
)
|
||||
|
||||
# Standard Fibonacci retracement ratios
|
||||
RETRACEMENT_RATIOS: list[float] = [0.236, 0.382, 0.5, 0.618, 0.786]
|
||||
|
||||
# Ratios considered "key" levels — proximity to these yields higher confidence
|
||||
_KEY_RATIOS: set[float] = {0.5, 0.618}
|
||||
|
||||
# Default minimum number of bars required for evaluation
|
||||
DEFAULT_MIN_BARS: int = 20
|
||||
|
||||
|
||||
class FibonacciEvaluator:
|
||||
"""Fibonacci retracement signal evaluator.
|
||||
|
||||
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||
protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
min_bars:
|
||||
Minimum number of OHLCV bars required before the evaluator will
|
||||
produce a signal. Defaults to ``20``.
|
||||
"""
|
||||
|
||||
def __init__(self, min_bars: int = DEFAULT_MIN_BARS) -> None:
|
||||
self.min_bars = min_bars
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (SignalEvaluator protocol)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate Fibonacci retracement on *bars* for *timeframe*.
|
||||
|
||||
Returns ``None`` when there are fewer than :pyattr:`min_bars` bars,
|
||||
or when the swing high equals the swing low (flat market — no valid
|
||||
retracement).
|
||||
"""
|
||||
if not validate_lookback(bars, self.min_bars):
|
||||
return None
|
||||
|
||||
# Detect swing high / swing low within the evaluation window
|
||||
sh_result = find_swing_high(bars, self.min_bars)
|
||||
sl_result = find_swing_low(bars, self.min_bars)
|
||||
|
||||
if sh_result is None or sl_result is None:
|
||||
return None
|
||||
|
||||
_sh_idx, sh_price = sh_result
|
||||
_sl_idx, sl_price = sl_result
|
||||
|
||||
# SH must be strictly greater than SL for a valid retracement range
|
||||
if sh_price <= sl_price:
|
||||
return None
|
||||
|
||||
price_range = sh_price - sl_price
|
||||
current_price = bars[-1].close
|
||||
|
||||
# Compute retracement levels: L(r) = SH - r * (SH - SL)
|
||||
levels: dict[float, float] = {
|
||||
r: sh_price - r * price_range for r in RETRACEMENT_RATIOS
|
||||
}
|
||||
|
||||
# Find the nearest retracement level to the current price
|
||||
nearest_ratio: float = RETRACEMENT_RATIOS[0]
|
||||
nearest_level: float = levels[nearest_ratio]
|
||||
min_distance: float = abs(current_price - nearest_level)
|
||||
|
||||
for ratio in RETRACEMENT_RATIOS[1:]:
|
||||
distance = abs(current_price - levels[ratio])
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
nearest_ratio = ratio
|
||||
nearest_level = levels[ratio]
|
||||
|
||||
# Signal strength: 1.0 - (distance / range), clamped to [0, 1]
|
||||
raw_strength = 1.0 - (min_distance / price_range)
|
||||
strength = max(0.0, min(1.0, raw_strength))
|
||||
|
||||
# Direction: BULLISH if price is near a retracement level and above SL
|
||||
# (potential bounce off support). Otherwise BEARISH.
|
||||
if current_price >= sl_price:
|
||||
direction = SignalDirection.BULLISH
|
||||
else:
|
||||
direction = SignalDirection.BEARISH
|
||||
|
||||
# Confidence: higher when the nearest level is a key ratio (0.618, 0.5)
|
||||
if nearest_ratio in _KEY_RATIOS:
|
||||
confidence = min(1.0, strength * 1.2)
|
||||
else:
|
||||
confidence = strength * 0.8
|
||||
|
||||
return SignalResult(
|
||||
signal_type="fibonacci",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=direction,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"swing_high": sh_price,
|
||||
"swing_low": sl_price,
|
||||
"retracement_levels": levels,
|
||||
"nearest_ratio": nearest_ratio,
|
||||
"nearest_level": nearest_level,
|
||||
"distance_to_nearest": min_distance,
|
||||
"current_price": current_price,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Moving average stack signal evaluator.
|
||||
|
||||
Detects bullish alignment (MA_10 > MA_20 > MA_50 > MA_200) and bearish
|
||||
alignment (MA_10 < MA_20 < MA_50 < MA_200), producing a signal strength
|
||||
proportional to the degree of alignment.
|
||||
|
||||
Full alignment (4/4 MAs in order) yields strength 1.0, partial alignment
|
||||
(3/4) yields 0.6, and no alignment returns ``None`` (no signal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||
from services.signal_engine.signals.base import compute_sma, validate_lookback
|
||||
|
||||
# MA periods used for stack evaluation
|
||||
MA_PERIODS: list[int] = [10, 20, 50, 200]
|
||||
|
||||
# Minimum number of bars required (longest MA period)
|
||||
MIN_BARS: int = 200
|
||||
|
||||
# Strength values
|
||||
_FULL_ALIGNMENT_STRENGTH: float = 1.0
|
||||
_PARTIAL_ALIGNMENT_STRENGTH: float = 0.6
|
||||
|
||||
# Confidence multiplier (high confidence for clear alignment patterns)
|
||||
_CONFIDENCE_MULTIPLIER: float = 0.9
|
||||
|
||||
|
||||
class MAStackEvaluator:
|
||||
"""Moving average stack signal evaluator.
|
||||
|
||||
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||
protocol.
|
||||
|
||||
Computes MA_10, MA_20, MA_50, and MA_200 and checks whether they are
|
||||
in bullish or bearish order. Full alignment (all four in strict order)
|
||||
produces strength 1.0; partial alignment (any three consecutive in order)
|
||||
produces strength 0.6. When no alignment is detected the evaluator
|
||||
returns ``None``.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (SignalEvaluator protocol)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate moving average stack alignment on *bars*.
|
||||
|
||||
Returns ``None`` when there are fewer than 200 bars (insufficient
|
||||
data for MA_200) or when no alignment is detected.
|
||||
"""
|
||||
if not validate_lookback(bars, MIN_BARS):
|
||||
return None
|
||||
|
||||
# Compute all four moving averages
|
||||
ma_10 = compute_sma(bars, 10)
|
||||
ma_20 = compute_sma(bars, 20)
|
||||
ma_50 = compute_sma(bars, 50)
|
||||
ma_200 = compute_sma(bars, 200)
|
||||
|
||||
# Safety check — compute_sma returns None on insufficient data
|
||||
if ma_10 is None or ma_20 is None or ma_50 is None or ma_200 is None:
|
||||
return None
|
||||
|
||||
ma_values = [ma_10, ma_20, ma_50, ma_200]
|
||||
|
||||
# Check full bullish alignment: MA_10 > MA_20 > MA_50 > MA_200
|
||||
full_bullish = ma_10 > ma_20 > ma_50 > ma_200
|
||||
|
||||
# Check full bearish alignment: MA_10 < MA_20 < MA_50 < MA_200
|
||||
full_bearish = ma_10 < ma_20 < ma_50 < ma_200
|
||||
|
||||
if full_bullish:
|
||||
return self._build_result(
|
||||
direction=SignalDirection.BULLISH,
|
||||
strength=_FULL_ALIGNMENT_STRENGTH,
|
||||
alignment="full_bullish",
|
||||
timeframe=timeframe,
|
||||
ma_values=ma_values,
|
||||
)
|
||||
|
||||
if full_bearish:
|
||||
return self._build_result(
|
||||
direction=SignalDirection.BEARISH,
|
||||
strength=_FULL_ALIGNMENT_STRENGTH,
|
||||
alignment="full_bearish",
|
||||
timeframe=timeframe,
|
||||
ma_values=ma_values,
|
||||
)
|
||||
|
||||
# Check partial alignment (3 out of 4 consecutive MAs in order)
|
||||
partial_bullish = self._check_partial_bullish(ma_values)
|
||||
partial_bearish = self._check_partial_bearish(ma_values)
|
||||
|
||||
if partial_bullish:
|
||||
return self._build_result(
|
||||
direction=SignalDirection.BULLISH,
|
||||
strength=_PARTIAL_ALIGNMENT_STRENGTH,
|
||||
alignment="partial_bullish",
|
||||
timeframe=timeframe,
|
||||
ma_values=ma_values,
|
||||
)
|
||||
|
||||
if partial_bearish:
|
||||
return self._build_result(
|
||||
direction=SignalDirection.BEARISH,
|
||||
strength=_PARTIAL_ALIGNMENT_STRENGTH,
|
||||
alignment="partial_bearish",
|
||||
timeframe=timeframe,
|
||||
ma_values=ma_values,
|
||||
)
|
||||
|
||||
# No alignment detected — no signal
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _check_partial_bullish(ma_values: list[float]) -> bool:
|
||||
"""Return ``True`` if any 3 consecutive MAs are in bullish order.
|
||||
|
||||
Checks windows [0:3] and [1:4] of the ordered MA list
|
||||
(MA_10, MA_20, MA_50, MA_200) for strictly descending values
|
||||
(higher MA value = bullish when shorter period > longer period).
|
||||
"""
|
||||
# Window 1: MA_10 > MA_20 > MA_50
|
||||
if ma_values[0] > ma_values[1] > ma_values[2]:
|
||||
return True
|
||||
# Window 2: MA_20 > MA_50 > MA_200
|
||||
if ma_values[1] > ma_values[2] > ma_values[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _check_partial_bearish(ma_values: list[float]) -> bool:
|
||||
"""Return ``True`` if any 3 consecutive MAs are in bearish order.
|
||||
|
||||
Checks windows [0:3] and [1:4] of the ordered MA list
|
||||
for strictly ascending values (lower MA value = bearish when
|
||||
shorter period < longer period).
|
||||
"""
|
||||
# Window 1: MA_10 < MA_20 < MA_50
|
||||
if ma_values[0] < ma_values[1] < ma_values[2]:
|
||||
return True
|
||||
# Window 2: MA_20 < MA_50 < MA_200
|
||||
if ma_values[1] < ma_values[2] < ma_values[3]:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_result(
|
||||
*,
|
||||
direction: SignalDirection,
|
||||
strength: float,
|
||||
alignment: str,
|
||||
timeframe: str,
|
||||
ma_values: list[float],
|
||||
) -> SignalResult:
|
||||
"""Construct a ``SignalResult`` for the MA stack signal."""
|
||||
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||
|
||||
return SignalResult(
|
||||
signal_type="ma_stack",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=direction,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"ma_10": ma_values[0],
|
||||
"ma_20": ma_values[1],
|
||||
"ma_50": ma_values[2],
|
||||
"ma_200": ma_values[3],
|
||||
"alignment": alignment,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""RSI (Relative Strength Index) signal evaluator.
|
||||
|
||||
Computes the standard 14-period RSI using Wilder's smoothing method and
|
||||
produces overbought (RSI > 70 → BEARISH) or oversold (RSI < 30 → BULLISH)
|
||||
signals with strength scaled by distance from the threshold.
|
||||
|
||||
When RSI is between 30 and 70 (neutral zone), no signal is produced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.signal_engine.models import OHLCVBar, SignalDirection, SignalResult
|
||||
from services.signal_engine.signals.base import validate_lookback
|
||||
|
||||
# Default RSI period (standard Wilder 14-period)
|
||||
DEFAULT_RSI_PERIOD: int = 14
|
||||
|
||||
# Minimum bars required: period + 1 (for initial price change calculation)
|
||||
DEFAULT_MIN_BARS: int = DEFAULT_RSI_PERIOD + 1 # 15
|
||||
|
||||
# Overbought / oversold thresholds
|
||||
OVERBOUGHT_THRESHOLD: float = 70.0
|
||||
OVERSOLD_THRESHOLD: float = 30.0
|
||||
|
||||
# Maximum possible distance from threshold (used for strength scaling)
|
||||
_MAX_DISTANCE_OVERBOUGHT: float = 100.0 - OVERBOUGHT_THRESHOLD # 30
|
||||
_MAX_DISTANCE_OVERSOLD: float = OVERSOLD_THRESHOLD - 0.0 # 30
|
||||
|
||||
# Confidence multiplier
|
||||
_CONFIDENCE_MULTIPLIER: float = 0.85
|
||||
|
||||
|
||||
def compute_rsi(bars: list[OHLCVBar], period: int = DEFAULT_RSI_PERIOD) -> float | None:
|
||||
"""Compute RSI using Wilder's smoothing method.
|
||||
|
||||
Args:
|
||||
bars: OHLCV bar series (oldest-first).
|
||||
period: RSI period (default 14).
|
||||
|
||||
Returns:
|
||||
RSI value in [0, 100], or ``None`` if insufficient data.
|
||||
"""
|
||||
min_bars = period + 1
|
||||
if len(bars) < min_bars:
|
||||
return None
|
||||
|
||||
closes = [bar.close for bar in bars]
|
||||
|
||||
# Calculate price changes
|
||||
changes = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||
|
||||
# Separate gains and losses for the first `period` changes
|
||||
first_gains = [max(0.0, c) for c in changes[:period]]
|
||||
first_losses = [max(0.0, -c) for c in changes[:period]]
|
||||
|
||||
avg_gain = sum(first_gains) / period
|
||||
avg_loss = sum(first_losses) / period
|
||||
|
||||
# Apply Wilder smoothing for subsequent changes
|
||||
for c in changes[period:]:
|
||||
gain = max(0.0, c)
|
||||
loss = max(0.0, -c)
|
||||
avg_gain = (avg_gain * (period - 1) + gain) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss) / period
|
||||
|
||||
# Avoid division by zero: if avg_loss is 0, RSI is 100
|
||||
if avg_loss == 0.0:
|
||||
return 100.0
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
rsi = 100.0 - (100.0 / (1.0 + rs))
|
||||
return rsi
|
||||
|
||||
|
||||
class RSIEvaluator:
|
||||
"""RSI signal evaluator.
|
||||
|
||||
Satisfies the :class:`~services.signal_engine.signals.base.SignalEvaluator`
|
||||
protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
period:
|
||||
RSI calculation period. Defaults to ``14``.
|
||||
"""
|
||||
|
||||
def __init__(self, period: int = DEFAULT_RSI_PERIOD) -> None:
|
||||
self.period = period
|
||||
self.min_bars = period + 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API (SignalEvaluator protocol)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
bars: list[OHLCVBar],
|
||||
timeframe: str,
|
||||
) -> SignalResult | None:
|
||||
"""Evaluate RSI on *bars* for *timeframe*.
|
||||
|
||||
Returns ``None`` when there are fewer than ``period + 1`` bars
|
||||
or when RSI is in the neutral zone (30–70).
|
||||
"""
|
||||
if not validate_lookback(bars, self.min_bars):
|
||||
return None
|
||||
|
||||
rsi = compute_rsi(bars, self.period)
|
||||
if rsi is None:
|
||||
return None
|
||||
|
||||
# Overbought: RSI > 70 → BEARISH (potential reversal down)
|
||||
if rsi > OVERBOUGHT_THRESHOLD:
|
||||
distance = rsi - OVERBOUGHT_THRESHOLD
|
||||
strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERBOUGHT))
|
||||
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||
return SignalResult(
|
||||
signal_type="rsi",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=SignalDirection.BEARISH,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"rsi": rsi,
|
||||
"period": self.period,
|
||||
"zone": "overbought",
|
||||
},
|
||||
)
|
||||
|
||||
# Oversold: RSI < 30 → BULLISH (potential reversal up)
|
||||
if rsi < OVERSOLD_THRESHOLD:
|
||||
distance = OVERSOLD_THRESHOLD - rsi
|
||||
strength = min(1.0, max(0.0, distance / _MAX_DISTANCE_OVERSOLD))
|
||||
confidence = strength * _CONFIDENCE_MULTIPLIER
|
||||
return SignalResult(
|
||||
signal_type="rsi",
|
||||
timeframe=timeframe,
|
||||
strength=strength,
|
||||
direction=SignalDirection.BULLISH,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"rsi": rsi,
|
||||
"period": self.period,
|
||||
"zone": "oversold",
|
||||
},
|
||||
)
|
||||
|
||||
# Neutral zone (30 ≤ RSI ≤ 70): no signal
|
||||
return None
|
||||
Reference in New Issue
Block a user