Files
stonks-oracle/services/signal_engine/signals/rsi.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

150 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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