Files
stonks-oracle/services/signal_engine/signals/elliott_wave.py
T
Celes Renata f468e30af0
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
feat: implement dual-pipeline signal engine service
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)
2026-05-02 07:32:26 +00:00

500 lines
16 KiB
Python

"""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)),
}