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

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:
Celes Renata
2026-05-02 07:32:26 +00:00
parent 7e2343ec2c
commit f468e30af0
61 changed files with 14107 additions and 184 deletions
@@ -0,0 +1 @@
# Signal Library - technical signal evaluators (Fibonacci, MA Stack, RSI, Cup & Handle, Elliott Wave)
+127
View File
@@ -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 1233%.
- 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)),
}
+127
View File
@@ -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,
},
)
+182
View File
@@ -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,
},
)
+149
View File
@@ -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 (3070).
"""
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