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
+1
View File
@@ -0,0 +1 @@
# Signal Engine - dual-pipeline signal evaluation (heuristic + probabilistic)
+355
View File
@@ -0,0 +1,355 @@
"""Signal engine configuration loaded from risk_configs + environment.
Defines ``SignalEngineConfig`` (the top-level dataclass) and four derived
sub-configs — ``HardFilterConfig``, ``HeuristicConfig``,
``ProbabilisticConfig``, ``ExitConfig`` — that expose relevant subsets for
cleaner function signatures.
``load_config()`` reads from the ``risk_configs`` table's JSONB ``config``
column and falls back to safe defaults on any error. Environment variables
with the ``SIGNAL_ENGINE_`` prefix override database values.
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Sub-configs — thin wrappers over relevant subsets of SignalEngineConfig
# ---------------------------------------------------------------------------
@dataclass
class HardFilterConfig:
"""Thresholds for the pre-pipeline hard filter engine."""
valuation_min: float = 0.3
earnings_days: int = 5
macro_bias_skip: float = -1.0
@dataclass
class HeuristicConfig:
"""Thresholds for the heuristic (deterministic) pipeline verdict."""
buy_confidence: float = 0.70
buy_s_total: float = 1.2
buy_valuation_min: float = 0.5
watch_confidence: float = 0.55
macro_bias_threshold: float = 0.0 # macro_bias must be > this for BUY
earnings_days_threshold: int = 5 # earnings_proximity must be > this for BUY
@dataclass
class ProbabilisticConfig:
"""Thresholds for the probabilistic (Bayesian) pipeline verdict."""
buy_p_up: float = 0.60
buy_entropy_max: float = 0.90
buy_ev_r_min: float = 1.5
buy_valuation_min: float = 0.5
watch_p_up: float = 0.55
watch_entropy_max: float = 0.95
entropy_skip: float = 0.95
# Regime priors
regime_prior_bull: float = 0.58
regime_prior_range: float = 0.50
regime_prior_bear: float = 0.42
# Fundamental gates (same semantics as heuristic)
macro_bias_threshold: float = 0.0
earnings_days_threshold: int = 5
@dataclass
class ExitConfig:
"""Configuration for the exit engine."""
trailing_stop_atr_multiplier: float = 2.0
# ---------------------------------------------------------------------------
# Top-level config
# ---------------------------------------------------------------------------
@dataclass
class SignalEngineConfig:
"""Configuration loaded from risk_configs + environment.
All fields carry safe defaults so that a fresh deployment works without
any database rows or environment variables.
"""
dual_pipeline_enabled: bool = False
heuristic_pipeline_enabled: bool = True
probabilistic_pipeline_enabled: bool = True
shadow_mode: bool = False
# Timeframe weights
timeframe_weights: dict[str, float] = field(
default_factory=lambda: {
"M30": 0.03,
"H1": 0.07,
"H4": 0.15,
"D": 0.30,
"W": 0.30,
"M": 0.15,
}
)
# Hard filter thresholds
hard_filter_valuation_min: float = 0.3
hard_filter_earnings_days: int = 5
hard_filter_macro_bias_skip: float = -1.0
# Heuristic verdict thresholds
heuristic_buy_confidence: float = 0.70
heuristic_buy_s_total: float = 1.2
heuristic_buy_valuation_min: float = 0.5
heuristic_watch_confidence: float = 0.55
# Probabilistic verdict thresholds
prob_buy_p_up: float = 0.60
prob_buy_entropy_max: float = 0.90
prob_buy_ev_r_min: float = 1.5
prob_buy_valuation_min: float = 0.5
prob_watch_p_up: float = 0.55
prob_watch_entropy_max: float = 0.95
prob_entropy_skip: float = 0.95
# Regime priors
regime_prior_bull: float = 0.58
regime_prior_range: float = 0.50
regime_prior_bear: float = 0.42
# Exit engine
trailing_stop_atr_multiplier: float = 2.0
# Polling
polling_interval_seconds: int = 30
# -- Derived sub-configs ------------------------------------------------
@property
def hard_filter_config(self) -> HardFilterConfig:
return HardFilterConfig(
valuation_min=self.hard_filter_valuation_min,
earnings_days=self.hard_filter_earnings_days,
macro_bias_skip=self.hard_filter_macro_bias_skip,
)
@property
def heuristic_config(self) -> HeuristicConfig:
return HeuristicConfig(
buy_confidence=self.heuristic_buy_confidence,
buy_s_total=self.heuristic_buy_s_total,
buy_valuation_min=self.heuristic_buy_valuation_min,
watch_confidence=self.heuristic_watch_confidence,
macro_bias_threshold=0.0,
earnings_days_threshold=self.hard_filter_earnings_days,
)
@property
def probabilistic_config(self) -> ProbabilisticConfig:
return ProbabilisticConfig(
buy_p_up=self.prob_buy_p_up,
buy_entropy_max=self.prob_buy_entropy_max,
buy_ev_r_min=self.prob_buy_ev_r_min,
buy_valuation_min=self.prob_buy_valuation_min,
watch_p_up=self.prob_watch_p_up,
watch_entropy_max=self.prob_watch_entropy_max,
entropy_skip=self.prob_entropy_skip,
regime_prior_bull=self.regime_prior_bull,
regime_prior_range=self.regime_prior_range,
regime_prior_bear=self.regime_prior_bear,
macro_bias_threshold=0.0,
earnings_days_threshold=self.hard_filter_earnings_days,
)
@property
def exit_config(self) -> ExitConfig:
return ExitConfig(
trailing_stop_atr_multiplier=self.trailing_stop_atr_multiplier,
)
# ---------------------------------------------------------------------------
# Config loading helpers
# ---------------------------------------------------------------------------
# SQL to fetch all signal_engine_* keys from the active risk_configs row's
# JSONB config column. The query extracts each top-level key/value pair and
# filters to those prefixed with ``signal_engine_``.
_CONFIG_QUERY = """
SELECT key, value
FROM (
SELECT key, value
FROM risk_configs,
jsonb_each_text(config)
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1
) sub
WHERE key LIKE 'signal_engine_%'
"""
# Mapping from risk_configs JSON key → SignalEngineConfig field name.
# Keys in the DB are prefixed ``signal_engine_`` which is stripped to match
# the dataclass field names.
_FIELD_TYPES: dict[str, type] = {
"dual_pipeline_enabled": bool,
"heuristic_pipeline_enabled": bool,
"probabilistic_pipeline_enabled": bool,
"shadow_mode": bool,
"timeframe_weights": dict,
"hard_filter_valuation_min": float,
"hard_filter_earnings_days": int,
"hard_filter_macro_bias_skip": float,
"heuristic_buy_confidence": float,
"heuristic_buy_s_total": float,
"heuristic_buy_valuation_min": float,
"heuristic_watch_confidence": float,
"prob_buy_p_up": float,
"prob_buy_entropy_max": float,
"prob_buy_ev_r_min": float,
"prob_buy_valuation_min": float,
"prob_watch_p_up": float,
"prob_watch_entropy_max": float,
"prob_entropy_skip": float,
"regime_prior_bull": float,
"regime_prior_range": float,
"regime_prior_bear": float,
"trailing_stop_atr_multiplier": float,
"polling_interval_seconds": int,
}
def _parse_value(raw: str, target_type: type) -> Any:
"""Coerce a raw string value from the DB/env into *target_type*.
Booleans accept ``true``/``false`` (case-insensitive).
Dicts are parsed as JSON.
"""
if target_type is bool:
return raw.lower() in ("true", "1", "yes")
if target_type is dict:
return json.loads(raw)
if target_type is int:
return int(raw)
if target_type is float:
return float(raw)
return raw
def _apply_db_rows(
config: SignalEngineConfig,
rows: list[tuple[str, str]],
) -> None:
"""Mutate *config* in-place from ``(key, value)`` DB rows.
Keys are expected to be prefixed ``signal_engine_`` — the prefix is
stripped before matching against dataclass fields.
"""
for key, value in rows:
field_name = key.removeprefix("signal_engine_")
target_type = _FIELD_TYPES.get(field_name)
if target_type is None:
logger.debug("Ignoring unknown signal_engine config key: %s", key)
continue
try:
parsed = _parse_value(value, target_type)
setattr(config, field_name, parsed)
except (ValueError, TypeError, json.JSONDecodeError):
logger.warning(
"Invalid value for signal_engine config key %s: %r — keeping default",
key,
value,
)
def _apply_env_overrides(config: SignalEngineConfig) -> None:
"""Override config fields from environment variables.
Environment variables use the ``SIGNAL_ENGINE_`` prefix (upper-case).
For example ``SIGNAL_ENGINE_DUAL_PIPELINE_ENABLED=true`` overrides
``dual_pipeline_enabled``.
"""
prefix = "SIGNAL_ENGINE_"
for env_key, env_value in os.environ.items():
if not env_key.startswith(prefix):
continue
field_name = env_key[len(prefix):].lower()
target_type = _FIELD_TYPES.get(field_name)
if target_type is None:
continue
try:
parsed = _parse_value(env_value, target_type)
setattr(config, field_name, parsed)
except (ValueError, TypeError, json.JSONDecodeError):
logger.warning(
"Invalid env override %s=%r — keeping previous value",
env_key,
env_value,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def load_config(pool: Any) -> SignalEngineConfig:
"""Load signal engine configuration from the database and environment.
1. Start with safe defaults (``SignalEngineConfig()``).
2. Query ``risk_configs`` for keys prefixed ``signal_engine_``.
3. Apply matching values over the defaults.
4. Apply environment variable overrides (``SIGNAL_ENGINE_*``).
5. On any DB error, fall back to defaults with ``dual_pipeline_enabled=False``.
The *pool* argument is an ``asyncpg.Pool`` (typed as ``Any`` to avoid a
hard import dependency at module level).
Requirements: 13.1, 13.6, 13.7
"""
config = SignalEngineConfig()
# Step 1 — read from risk_configs
try:
rows = await pool.fetch(_CONFIG_QUERY)
if rows:
_apply_db_rows(config, [(r["key"], r["value"]) for r in rows])
except Exception:
logger.warning(
"Failed to load signal engine config from risk_configs — "
"defaulting to disabled (fail-safe)",
exc_info=True,
)
# Ensure fail-safe: dual pipeline stays off
config.dual_pipeline_enabled = False
# Step 2 — environment overrides (always applied, even after DB failure)
_apply_env_overrides(config)
logger.info(
"Signal engine config loaded: dual_pipeline_enabled=%s, "
"heuristic=%s, probabilistic=%s, shadow_mode=%s, "
"polling_interval=%ds",
config.dual_pipeline_enabled,
config.heuristic_pipeline_enabled,
config.probabilistic_pipeline_enabled,
config.shadow_mode,
config.polling_interval_seconds,
)
return config
+136
View File
@@ -0,0 +1,136 @@
"""Multi-Timeframe Confluence Engine.
Evaluates signals across multiple timeframes and computes weighted confluence
scores. Signals must trigger on at least 2 timeframes **and** include at
least one higher-timeframe anchor (D, W, or M) to pass the confluence filter.
The weighted confluence score is:
C_confluence = Σ(w_tf · s_tf)
where ``w_tf`` is the timeframe weight and ``s_tf`` is the signal strength on
that timeframe (only summed over timeframes where the signal triggered).
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
"""
from __future__ import annotations
import logging
from collections import Counter
from services.signal_engine.models import (
ConfluenceSignal,
SignalDirection,
SignalResult,
)
logger = logging.getLogger(__name__)
# Higher-timeframe anchors — at least one must be present for a signal to pass.
HIGHER_TIMEFRAME_ANCHORS: frozenset[str] = frozenset({"D", "W", "M"})
# Minimum number of timeframes a signal must trigger on.
MIN_TIMEFRAME_COUNT: int = 2
def _dominant_direction(results: dict[str, SignalResult]) -> SignalDirection:
"""Determine the dominant direction from a set of per-timeframe results.
Counts bullish vs bearish votes across active timeframes. Ties resolve
to NEUTRAL.
"""
counts: Counter[SignalDirection] = Counter()
for sr in results.values():
counts[sr.direction] += 1
bullish = counts.get(SignalDirection.BULLISH, 0)
bearish = counts.get(SignalDirection.BEARISH, 0)
if bullish > bearish:
return SignalDirection.BULLISH
if bearish > bullish:
return SignalDirection.BEARISH
return SignalDirection.NEUTRAL
def compute_confluence(
signal_results: dict[str, dict[str, SignalResult]],
weights: dict[str, float],
) -> list[ConfluenceSignal]:
"""Compute weighted confluence scores across timeframes.
Args:
signal_results: ``{signal_type: {timeframe: SignalResult}}``.
Each inner dict maps timeframe labels (e.g. ``"D"``, ``"H4"``)
to the :class:`SignalResult` produced by the signal evaluator on
that timeframe.
weights: ``{timeframe: weight}`` e.g.
``{"M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15}``.
Returns:
List of :class:`ConfluenceSignal` objects that pass **both** filters:
1. **Minimum confluence threshold** — the signal must trigger on at
least :data:`MIN_TIMEFRAME_COUNT` (2) timeframes.
2. **Higher-timeframe anchor** — at least one of D, W, or M must be
among the active timeframes.
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6
"""
confluence_signals: list[ConfluenceSignal] = []
for signal_type, tf_results in signal_results.items():
active_timeframes = list(tf_results.keys())
# 3.3 — Minimum confluence threshold: discard if < 2 timeframes
if len(active_timeframes) < MIN_TIMEFRAME_COUNT:
logger.debug(
"Signal %s discarded: only %d timeframe(s) triggered (need >= %d)",
signal_type,
len(active_timeframes),
MIN_TIMEFRAME_COUNT,
)
continue
# 3.4 — Higher-timeframe anchor: discard if none of D, W, M present
if not HIGHER_TIMEFRAME_ANCHORS.intersection(active_timeframes):
logger.debug(
"Signal %s discarded: no higher-timeframe anchor (D/W/M) "
"among active timeframes %s",
signal_type,
active_timeframes,
)
continue
# 3.2 — Compute weighted confluence score
per_timeframe: dict[str, float] = {}
confluence_score = 0.0
for tf, sr in tf_results.items():
w = weights.get(tf, 0.0)
per_timeframe[tf] = sr.strength
confluence_score += w * sr.strength
# Determine dominant direction across active timeframes
direction = _dominant_direction(tf_results)
confluence_signals.append(
ConfluenceSignal(
signal_type=signal_type,
direction=direction,
confluence_score=confluence_score,
active_timeframes=active_timeframes,
per_timeframe=per_timeframe,
)
)
logger.debug(
"Signal %s passed confluence: score=%.4f, direction=%s, "
"timeframes=%s",
signal_type,
confluence_score,
direction.value,
active_timeframes,
)
return confluence_signals
+137
View File
@@ -0,0 +1,137 @@
"""Signal cluster classification and within-cluster correlation penalty.
Groups signals into four clusters — momentum, structure, volatility,
fundamentals — and applies exponential decay within each cluster to prevent
likelihood ratio stacking inflation in the Bayesian pipeline.
Within a cluster the strongest signal (by ``|log_lr|``) contributes at full
weight; subsequent signals contribute at ``0.5^(n-1)`` decay. Signals in
different clusters are treated as independent (no penalty). Single-signal
clusters receive no penalty.
Requirements: 7.1, 7.2, 7.3, 7.4
"""
from __future__ import annotations
import logging
from collections import defaultdict
from enum import Enum
from services.signal_engine.models import LikelihoodRatio
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Signal cluster enum
# ---------------------------------------------------------------------------
class SignalCluster(str, Enum):
"""Correlation cluster for grouping related signals."""
MOMENTUM = "momentum" # MA stack, RSI
STRUCTURE = "structure" # Fibonacci, Elliott Wave, Cup & Handle
VOLATILITY = "volatility" # ATR-based, Bollinger-derived
FUNDAMENTALS = "fundamentals" # valuation, earnings, macro
# ---------------------------------------------------------------------------
# Signal type → cluster mapping
# ---------------------------------------------------------------------------
_SIGNAL_CLUSTER_MAP: dict[str, SignalCluster] = {
# Momentum
"ma_stack": SignalCluster.MOMENTUM,
"rsi": SignalCluster.MOMENTUM,
# Structure
"fibonacci": SignalCluster.STRUCTURE,
"elliott_wave": SignalCluster.STRUCTURE,
"cup_handle": SignalCluster.STRUCTURE,
# Volatility
"atr": SignalCluster.VOLATILITY,
"bollinger": SignalCluster.VOLATILITY,
# Fundamentals
"valuation": SignalCluster.FUNDAMENTALS,
"earnings": SignalCluster.FUNDAMENTALS,
"macro": SignalCluster.FUNDAMENTALS,
}
# Decay factor applied to successive signals within the same cluster.
_WITHIN_CLUSTER_DECAY = 0.5
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def classify_signal(signal_type: str) -> SignalCluster:
"""Map a signal type string to its correlation cluster.
Falls back to :pyattr:`SignalCluster.FUNDAMENTALS` for unknown signal
types so that unrecognised signals still participate in the penalty
system rather than silently bypassing it.
"""
cluster = _SIGNAL_CLUSTER_MAP.get(signal_type)
if cluster is None:
logger.warning(
"Unknown signal type %r — defaulting to FUNDAMENTALS cluster",
signal_type,
)
return SignalCluster.FUNDAMENTALS
return cluster
def apply_correlation_penalty(
likelihood_ratios: list[LikelihoodRatio],
) -> list[LikelihoodRatio]:
"""Apply within-cluster decay penalty to correlated signals.
Algorithm:
1. Group LRs by cluster.
2. Within each cluster, sort by ``abs(log_lr)`` descending (strongest
first).
3. The strongest signal keeps its full ``log_lr`` as
``penalized_log_lr``.
4. The *n*-th signal (1-indexed) receives
``penalized_log_lr = log_lr * 0.5^(n-1)``.
5. Single-signal clusters are untouched (``penalized_log_lr = log_lr``).
6. Cross-cluster signals are independent — no penalty applied across
clusters.
Returns a **new** list of :class:`LikelihoodRatio` instances with
updated ``penalized_log_lr`` values. The original objects are not
mutated.
"""
if not likelihood_ratios:
return []
# Group by cluster
clusters: dict[str, list[tuple[int, LikelihoodRatio]]] = defaultdict(list)
for idx, lr in enumerate(likelihood_ratios):
clusters[lr.cluster].append((idx, lr))
# Build result list preserving original order
result: list[LikelihoodRatio | None] = [None] * len(likelihood_ratios)
for cluster_name, members in clusters.items():
# Sort by abs(log_lr) descending — strongest first
sorted_members = sorted(members, key=lambda t: abs(t[1].log_lr), reverse=True)
for rank, (orig_idx, lr) in enumerate(sorted_members):
decay = _WITHIN_CLUSTER_DECAY ** rank # 0.5^0=1, 0.5^1=0.5, ...
penalized = lr.log_lr * decay
result[orig_idx] = LikelihoodRatio(
signal_type=lr.signal_type,
cluster=lr.cluster,
lr=lr.lr,
log_lr=lr.log_lr,
penalized_log_lr=penalized,
hit_rate=lr.hit_rate,
strength=lr.strength,
)
# Safety: should never happen, but guard against it
return [r for r in result if r is not None]
+139
View File
@@ -0,0 +1,139 @@
"""Delta Analyzer — compares heuristic and probabilistic pipeline verdicts.
Computes agreement flags, confidence deltas, disagreement reasons, and
tracks a rolling 100-evaluation agreement rate per ticker in Redis.
Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6
"""
from __future__ import annotations
import logging
import redis.asyncio
from services.signal_engine.models import (
DeltaResult,
HeuristicResult,
ProbabilisticResult,
Verdict,
)
logger = logging.getLogger(__name__)
# Redis key pattern for rolling agreement tracking
_AGREEMENT_KEY_PREFIX = "stonks:signal_engine:agreement"
# Maximum number of evaluations to track for rolling agreement rate
_ROLLING_WINDOW = 100
# Agreement rate threshold below which a warning is logged
_AGREEMENT_WARNING_THRESHOLD = 0.50
def _compute_disagreement_reasons(
heuristic: HeuristicResult,
probabilistic: ProbabilisticResult,
) -> list[str]:
"""Identify reasons for pipeline disagreement.
Compares which conditions each pipeline met or failed to produce
human-readable disagreement reasons for training signal generation.
"""
reasons: list[str] = []
if heuristic.verdict == probabilistic.verdict:
return reasons
# Heuristic-side reasons
if heuristic.confidence < 0.70:
reasons.append("heuristic_confidence_below_threshold")
if heuristic.s_total < 1.2:
reasons.append("heuristic_s_total_below_threshold")
# Probabilistic-side reasons
if probabilistic.p_up < 0.60:
reasons.append("probabilistic_p_up_below_threshold")
if probabilistic.entropy > 0.90:
reasons.append("probabilistic_entropy_too_high")
if probabilistic.ev_r < 1.5:
reasons.append("EV_R_below_threshold")
# Verdict-specific context
if heuristic.verdict == Verdict.BUY and probabilistic.verdict != Verdict.BUY:
reasons.append("heuristic_buy_probabilistic_disagrees")
elif probabilistic.verdict == Verdict.BUY and heuristic.verdict != Verdict.BUY:
reasons.append("probabilistic_buy_heuristic_disagrees")
return reasons
async def analyze_delta(
heuristic: HeuristicResult,
probabilistic: ProbabilisticResult,
redis_client: redis.asyncio.Redis,
ticker: str,
) -> DeltaResult:
"""Compare pipeline verdicts and track agreement metrics.
1. Compute agreement flag (both verdicts identical).
2. Compute confidence delta: ``|heuristic_confidence - probabilistic_P_up|``.
3. Record disagreement reasons when verdicts differ.
4. Track rolling 100-evaluation agreement rate in Redis.
5. Log warning when agreement rate drops below 0.50.
Returns a ``DeltaResult`` with all computed fields.
"""
# Step 1: Agreement flag
agreement = heuristic.verdict == probabilistic.verdict
# Step 2: Confidence delta
confidence_delta = abs(heuristic.confidence - probabilistic.p_up)
# Step 3: Disagreement reasons
disagreement_reasons = _compute_disagreement_reasons(heuristic, probabilistic)
# Step 4: Rolling agreement rate in Redis
rolling_agreement_rate: float | None = None
agreement_key = f"{_AGREEMENT_KEY_PREFIX}:{ticker}"
try:
# Push the agreement result (1 for agree, 0 for disagree)
await redis_client.lpush(agreement_key, "1" if agreement else "0")
# Trim to the last _ROLLING_WINDOW evaluations
await redis_client.ltrim(agreement_key, 0, _ROLLING_WINDOW - 1)
# Compute the rolling agreement rate
values = await redis_client.lrange(agreement_key, 0, _ROLLING_WINDOW - 1)
if values:
agree_count = sum(1 for v in values if v == b"1" or v == "1")
rolling_agreement_rate = agree_count / len(values)
except Exception:
logger.warning(
"Failed to update rolling agreement rate in Redis for %s",
ticker,
exc_info=True,
)
# Step 5: Log warning when agreement rate drops below threshold
if (
rolling_agreement_rate is not None
and rolling_agreement_rate < _AGREEMENT_WARNING_THRESHOLD
):
logger.warning(
"Persistent pipeline disagreement for %s: rolling agreement rate %.2f "
"(below %.2f threshold over last %d evaluations)",
ticker,
rolling_agreement_rate,
_AGREEMENT_WARNING_THRESHOLD,
_ROLLING_WINDOW,
)
# Step 6: Return DeltaResult
return DeltaResult(
agreement=agreement,
confidence_delta=round(confidence_delta, 6),
heuristic_verdict=heuristic.verdict.value,
probabilistic_verdict=probabilistic.verdict.value,
disagreement_reasons=disagreement_reasons,
rolling_agreement_rate=rolling_agreement_rate,
)
+154
View File
@@ -0,0 +1,154 @@
"""Exit engine — position-level exit management.
Evaluates stop-loss hits, take-profit targets, and trailing ATR-based stops
for open positions. Called once per evaluation tick *before* the signal
pipelines run so that exit signals take priority over new entry signals.
Priority order (first match wins per position):
1. stop_loss hit → EXIT_FULL, reason ``"stop_hit"``
2. target_2 hit → EXIT_FULL, reason ``"target_2_hit"``
3. trailing stop → EXIT_FULL, reason ``"trailing_stop_hit"``
4. target_1 hit → EXIT_HALF, reason ``"target_1_hit"``
Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
"""
from __future__ import annotations
import logging
from services.signal_engine.config import ExitConfig
from services.signal_engine.models import (
ExitSignal,
ExitType,
OpenPositionState,
)
logger = logging.getLogger(__name__)
def evaluate_exits(
positions: list[OpenPositionState],
current_prices: dict[str, float],
config: ExitConfig,
) -> list[ExitSignal]:
"""Evaluate exit conditions for all open positions.
For each position the current price is looked up in *current_prices*
(keyed by ticker). If the ticker is absent the position's own
``current_price`` field is used as a fallback.
Checks are applied in priority order — only the **first** matching
condition per position emits an ``ExitSignal``.
Parameters
----------
positions:
Snapshots of open positions to evaluate.
current_prices:
Latest prices keyed by ticker symbol.
config:
Exit engine configuration (trailing stop ATR multiplier, etc.).
Returns
-------
list[ExitSignal]
One signal per position that triggered an exit condition.
Positions with no exit condition produce no signal.
"""
signals: list[ExitSignal] = []
for pos in positions:
price = current_prices.get(pos.ticker, pos.current_price)
signal = _evaluate_single_position(pos, price, config)
if signal is not None:
signals.append(signal)
return signals
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _evaluate_single_position(
pos: OpenPositionState,
price: float,
config: ExitConfig,
) -> ExitSignal | None:
"""Check exit conditions for a single position in priority order.
Priority: stop_loss > target_2 > trailing_stop > target_1.
"""
# 1. Stop-loss hit (highest priority)
if price <= pos.stop_loss:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="stop_hit",
price=price,
)
# 2. Target 2 hit → full exit
if price >= pos.target_2:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="target_2_hit",
price=price,
)
# 3. Trailing stop (only active after partial exit)
if pos.partial_exit_done:
trailing_stop = _compute_trailing_stop(pos, price, config)
if price <= trailing_stop:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_FULL,
reason="trailing_stop_hit",
price=price,
)
# 4. Target 1 hit → partial exit (only if not already done)
if not pos.partial_exit_done and price >= pos.target_1:
return ExitSignal(
position_id=pos.position_id,
ticker=pos.ticker,
exit_type=ExitType.EXIT_HALF,
reason="target_1_hit",
price=price,
)
return None
def _compute_trailing_stop(
pos: OpenPositionState,
price: float,
config: ExitConfig,
) -> float:
"""Compute the effective trailing stop level.
The trailing stop is ``price - ATR * multiplier``, but it only
ratchets **upward** — if the position already has a higher trailing
stop recorded, that value is kept.
When ATR is unavailable (``None``), the existing ``trailing_stop``
on the position is returned as-is. If neither is set, returns 0.0
(effectively no trailing stop).
"""
existing = pos.trailing_stop if pos.trailing_stop is not None else 0.0
if pos.atr is None:
return existing
new_level = price - pos.atr * config.trailing_stop_atr_multiplier
# Ratchet upward only
return max(existing, new_level)
+233
View File
@@ -0,0 +1,233 @@
"""Output Formatter — assembles the structured SignalOutput contract.
Populates trade plans based on verdict combinations and maps
``SignalOutput`` to the existing ``Recommendation`` schema for
trading engine compatibility.
Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1, 12.2, 12.3, 12.4, 12.5
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.shared.schemas import (
ActionType,
PositionSizing,
Recommendation,
RecommendationMode,
)
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.models import (
DeltaResult,
ExitSignal,
HeuristicResult,
ProbabilisticResult,
SignalOutput,
TradePlan,
Verdict,
)
# ---------------------------------------------------------------------------
# Position sizing constants
# ---------------------------------------------------------------------------
# Full position sizing (heuristic-only or dual confirmed)
_FULL_POSITION_SIZE_PCT = 0.02
_FULL_MAX_LOSS_PCT = 0.005
# Reduced position sizing for probabilistic-only BUY (50% of standard)
_REDUCED_POSITION_SIZE_PCT = 0.01
# Trade plan price levels (relative to entry)
_STOP_LOSS_FACTOR = 0.95
_TARGET_1_FACTOR = 1.05
_TARGET_2_FACTOR = 1.10
def _build_trade_plan(
price: float,
*,
dual_confirmed: bool = False,
probabilistic_only: bool = False,
) -> TradePlan:
"""Build a trade plan with position sizing based on confirmation mode.
- dual_confirmed: full position sizing with dual_confirmed flag
- probabilistic_only: 50% position sizing with probabilistic_only flag
- heuristic-only (neither flag): standard full position sizing
"""
if dual_confirmed:
position_size_pct = _FULL_POSITION_SIZE_PCT
max_loss_pct = _FULL_MAX_LOSS_PCT
elif probabilistic_only:
position_size_pct = _REDUCED_POSITION_SIZE_PCT
max_loss_pct = _FULL_MAX_LOSS_PCT
else:
# Heuristic-only BUY
position_size_pct = _FULL_POSITION_SIZE_PCT
max_loss_pct = _FULL_MAX_LOSS_PCT
return TradePlan(
entry_price=price,
stop_loss=round(price * _STOP_LOSS_FACTOR, 6),
target_1=round(price * _TARGET_1_FACTOR, 6),
target_2=round(price * _TARGET_2_FACTOR, 6),
position_size_pct=position_size_pct,
max_loss_pct=max_loss_pct,
dual_confirmed=dual_confirmed,
probabilistic_only=probabilistic_only,
)
def format_output(
ticker: str,
price: float,
heuristic: HeuristicResult,
probabilistic: ProbabilisticResult,
delta: DeltaResult,
exit_signals: list[ExitSignal],
config: SignalEngineConfig,
) -> SignalOutput:
"""Assemble the structured ``SignalOutput`` contract.
Trade plan logic:
- Both BUY → ``dual_confirmed``, full position sizing
- Probabilistic-only BUY → ``probabilistic_only``, 50% position sizing
- Heuristic-only BUY → standard position sizing
- No BUY → no trade_plan (WATCH/SKIP persisted for analysis)
"""
heuristic_buy = heuristic.verdict == Verdict.BUY
probabilistic_buy = probabilistic.verdict == Verdict.BUY
trade_plan: TradePlan | None = None
if heuristic_buy and probabilistic_buy:
# Both pipelines agree on BUY → dual confirmed
trade_plan = _build_trade_plan(
price, dual_confirmed=True, probabilistic_only=False
)
elif probabilistic_buy and not heuristic_buy:
# Probabilistic-only BUY → reduced position sizing
trade_plan = _build_trade_plan(
price, dual_confirmed=False, probabilistic_only=True
)
elif heuristic_buy and not probabilistic_buy:
# Heuristic-only BUY → standard position sizing
trade_plan = _build_trade_plan(
price, dual_confirmed=False, probabilistic_only=False
)
# else: No BUY → no trade_plan
return SignalOutput(
ticker=ticker,
timestamp=datetime.now(tz=timezone.utc),
price=price,
# Heuristic pipeline section
heuristic_verdict=heuristic.verdict.value,
heuristic_confidence=heuristic.confidence,
heuristic_s_total=heuristic.s_total,
# Probabilistic pipeline section
probabilistic_verdict=probabilistic.verdict.value,
probabilistic_p_up=probabilistic.p_up,
probabilistic_entropy=probabilistic.entropy,
probabilistic_ev_r=probabilistic.ev_r,
# Delta analysis section
delta_agreement=delta.agreement,
delta_confidence_delta=delta.confidence_delta,
delta_reasons=delta.disagreement_reasons,
# Trade plan and exit signals
trade_plan=trade_plan,
exit_signals=exit_signals,
# Detail payloads for audit
heuristic_detail=heuristic.model_dump(),
probabilistic_detail=probabilistic.model_dump(),
# Pipeline mode metadata
pipeline_mode="dual_pipeline",
shadow_mode=config.shadow_mode,
)
def signal_output_to_recommendation(output: SignalOutput) -> Recommendation:
"""Map a ``SignalOutput`` to the existing ``Recommendation`` schema.
Enables the trading engine to consume dual-pipeline outputs without
modification to its core ``evaluate_recommendation`` logic.
Confidence mapping:
- Dual confirmed: ``max(heuristic_confidence, probabilistic_P_up)``
- Probabilistic only: ``probabilistic_P_up * 0.8`` (20% haircut)
- Heuristic only: ``heuristic_confidence``
- No BUY: ``max(heuristic_confidence, probabilistic_P_up)``
Action mapping:
- BUY (either pipeline) → ``ActionType.BUY``
- WATCH → ``ActionType.WATCH``
- SKIP → ``ActionType.HOLD``
Mode: always ``RecommendationMode.PAPER_ELIGIBLE``
"""
trade_plan = output.trade_plan
# Determine confidence based on confirmation mode
if trade_plan is not None and trade_plan.dual_confirmed:
confidence = max(output.heuristic_confidence, output.probabilistic_p_up)
elif trade_plan is not None and trade_plan.probabilistic_only:
confidence = output.probabilistic_p_up * 0.8
elif trade_plan is not None:
# Heuristic-only BUY
confidence = output.heuristic_confidence
else:
# No trade plan — use the best available confidence
confidence = max(output.heuristic_confidence, output.probabilistic_p_up)
# Clamp confidence to [0, 1]
confidence = max(0.0, min(1.0, confidence))
# Determine action from verdicts
h_verdict = output.heuristic_verdict
p_verdict = output.probabilistic_verdict
if h_verdict == Verdict.BUY.value or p_verdict == Verdict.BUY.value:
action = ActionType.BUY
elif h_verdict == Verdict.WATCH.value or p_verdict == Verdict.WATCH.value:
action = ActionType.WATCH
else:
action = ActionType.HOLD
# Build position sizing from trade plan if available
position_sizing = PositionSizing()
if trade_plan is not None:
position_sizing = PositionSizing(
portfolio_pct=trade_plan.position_size_pct,
max_loss_pct=trade_plan.max_loss_pct,
)
# Build thesis from delta analysis
thesis_parts: list[str] = []
if trade_plan is not None and trade_plan.dual_confirmed:
thesis_parts.append("Dual-pipeline confirmed BUY signal")
elif trade_plan is not None and trade_plan.probabilistic_only:
thesis_parts.append("Probabilistic-only BUY signal (reduced sizing)")
elif trade_plan is not None:
thesis_parts.append("Heuristic-only BUY signal")
else:
thesis_parts.append(f"No BUY signal (H={h_verdict}, P={p_verdict})")
if output.delta_reasons:
thesis_parts.append(f"Delta reasons: {', '.join(output.delta_reasons)}")
return Recommendation(
recommendation_id=output.output_id,
ticker=output.ticker,
action=action,
mode=RecommendationMode.PAPER_ELIGIBLE,
confidence=confidence,
time_horizon="signal_engine",
thesis="; ".join(thesis_parts),
position_sizing=position_sizing,
pipeline_mode="dual_pipeline",
p_bull=output.probabilistic_p_up,
expected_value=output.probabilistic_ev_r,
generated_at=output.timestamp,
)
+80
View File
@@ -0,0 +1,80 @@
"""Hard Filter Engine — pre-pipeline gating for the dual-pipeline signal engine.
Evaluates macro bias, valuation score, and earnings proximity to short-circuit
both pipelines before evaluation. All conditions are checked and all triggered
reasons are collected (no short-circuit on first match).
Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from services.signal_engine.config import HardFilterConfig
from services.signal_engine.models import NormalizedInput
logger = logging.getLogger(__name__)
@dataclass
class HardFilterResult:
"""Outcome of the hard filter evaluation.
``filtered=True`` means the ticker should be **skipped** — both pipelines
are short-circuited. ``reasons`` lists every filter that triggered.
"""
filtered: bool = False
reasons: list[str] = field(default_factory=list)
def evaluate_hard_filters(
normalized: NormalizedInput,
config: HardFilterConfig,
) -> HardFilterResult:
"""Evaluate pre-pipeline hard filters.
Checks (all evaluated, not short-circuited):
- ``macro_bias == config.macro_bias_skip`` → reason ``"macro_bias_negative"``
- ``valuation_score < config.valuation_min`` → reason ``"valuation_below_threshold"``
- ``earnings_proximity_days <= config.earnings_days`` → reason ``"earnings_block"``
Missing optional fields (``valuation_score is None``,
``earnings_proximity_days is None``) do **not** trigger a filter — missing
data should not produce a false-positive SKIP.
Returns a :class:`HardFilterResult` with ``filtered=True`` when at least
one reason was recorded.
"""
reasons: list[str] = []
# 4.1 — macro_bias exact equality with configured skip value
if normalized.macro_bias == config.macro_bias_skip:
reasons.append("macro_bias_negative")
# 4.2 — valuation score below minimum threshold
if (
normalized.valuation_score is not None
and normalized.valuation_score < config.valuation_min
):
reasons.append("valuation_below_threshold")
# 4.3 — earnings proximity within block window
if (
normalized.earnings_proximity_days is not None
and normalized.earnings_proximity_days <= config.earnings_days
):
reasons.append("earnings_block")
filtered = len(reasons) > 0
if filtered:
logger.info(
"Hard filter triggered for %s: %s",
normalized.ticker,
", ".join(reasons),
)
return HardFilterResult(filtered=filtered, reasons=reasons)
+299
View File
@@ -0,0 +1,299 @@
"""Heuristic Pipeline (Pipeline A) — Deterministic scoring and verdict.
Computes ``S_total = S_company + S_macro + S_competitive`` from confluence-
filtered signals and produces a confidence-gated BUY / WATCH / SKIP verdict.
The pipeline reuses the existing ``compute_signal_weight`` infrastructure
from ``services.aggregation.scoring`` for signal weighting and follows the
three-layer signal aggregation model (company, macro, competitive).
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
"""
from __future__ import annotations
import logging
from services.signal_engine.config import HeuristicConfig
from services.signal_engine.models import (
ConfluenceSignal,
HeuristicResult,
NormalizedInput,
SignalDirection,
Verdict,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Signal classification — which confluence signals belong to which layer
# ---------------------------------------------------------------------------
# Company-level technical signals (Layer 1)
COMPANY_SIGNAL_TYPES: frozenset[str] = frozenset({
"fibonacci",
"ma_stack",
"rsi",
"cup_handle",
"elliott_wave",
})
# Competitive signals (Layer 3) — future expansion
COMPETITIVE_SIGNAL_TYPES: frozenset[str] = frozenset()
# Macro weight applied to macro_bias to produce S_macro
_MACRO_WEIGHT: float = 0.5
# ---------------------------------------------------------------------------
# Score computation helpers
# ---------------------------------------------------------------------------
def _compute_s_company(confluence_signals: list[ConfluenceSignal]) -> tuple[float, list[dict]]:
"""Sum confluence scores for company-level signals.
Returns the total S_company score and a list of per-signal weight
breakdowns for audit.
"""
s_company = 0.0
weights: list[dict] = []
for sig in confluence_signals:
if sig.signal_type in COMPANY_SIGNAL_TYPES:
# Direction-aware: bullish contributes positively, bearish negatively
direction_sign = _direction_sign(sig.direction)
contribution = sig.confluence_score * direction_sign
s_company += contribution
weights.append({
"signal_type": sig.signal_type,
"layer": "company",
"confluence_score": sig.confluence_score,
"direction": sig.direction.value,
"contribution": contribution,
"active_timeframes": sig.active_timeframes,
})
return s_company, weights
def _compute_s_macro(normalized: NormalizedInput) -> float:
"""Compute macro score from macro_bias.
S_macro = macro_bias * weight, where macro_bias is in [-1.0, 1.0].
A positive macro_bias contributes positively; negative contributes
negatively.
"""
return normalized.macro_bias * _MACRO_WEIGHT
def _compute_s_competitive(confluence_signals: list[ConfluenceSignal]) -> float:
"""Sum confluence scores for competitive-layer signals.
Currently returns 0.0 as no competitive signal types are defined in
the signal library. This is a placeholder for future expansion.
"""
s_competitive = 0.0
for sig in confluence_signals:
if sig.signal_type in COMPETITIVE_SIGNAL_TYPES:
direction_sign = _direction_sign(sig.direction)
s_competitive += sig.confluence_score * direction_sign
return s_competitive
def _direction_sign(direction: SignalDirection) -> float:
"""Map signal direction to a numeric sign."""
if direction == SignalDirection.BULLISH:
return 1.0
if direction == SignalDirection.BEARISH:
return -1.0
return 0.0
# ---------------------------------------------------------------------------
# Confidence computation
# ---------------------------------------------------------------------------
def _compute_confidence(
confluence_signals: list[ConfluenceSignal],
) -> float:
"""Compute pipeline confidence from confluence signals.
Confidence is derived from:
1. **Base confidence** — average signal strength across all confluence
signals (mean of confluence_score values).
2. **Source count boost** — more active signals increase confidence
(diminishing returns, capped contribution).
3. **Signal agreement boost** — if all signals point in the same
direction, confidence is boosted.
4. **Contradiction penalty** — if signals disagree on direction,
confidence is penalised.
Returns a value clamped to [0.0, 1.0].
"""
if not confluence_signals:
return 0.0
# 1. Base confidence: average confluence score (already weighted by
# timeframe importance)
total_score = sum(s.confluence_score for s in confluence_signals)
base_confidence = total_score / len(confluence_signals)
# 2. Source count factor: more signals → higher confidence, with
# diminishing returns. 1 signal → 0.6, 2 → 0.75, 3 → 0.85,
# 4 → 0.90, 5+ → 0.95 (asymptotic).
n = len(confluence_signals)
source_factor = 1.0 - (0.4 / n) # approaches 1.0 as n grows
# 3. Signal agreement / contradiction
directions = [s.direction for s in confluence_signals]
bullish_count = sum(1 for d in directions if d == SignalDirection.BULLISH)
bearish_count = sum(1 for d in directions if d == SignalDirection.BEARISH)
if n == 1:
agreement_factor = 1.0
elif bullish_count == n or bearish_count == n:
# Perfect agreement — boost
agreement_factor = 1.15
elif bullish_count > 0 and bearish_count > 0:
# Contradiction — penalty proportional to minority fraction
minority = min(bullish_count, bearish_count)
contradiction_ratio = minority / n
agreement_factor = 1.0 - (0.3 * contradiction_ratio)
else:
# Mix of directional and neutral — mild boost
agreement_factor = 1.05
confidence = base_confidence * source_factor * agreement_factor
return max(0.0, min(confidence, 1.0))
# ---------------------------------------------------------------------------
# Verdict logic
# ---------------------------------------------------------------------------
def _determine_verdict(
confidence: float,
s_total: float,
normalized: NormalizedInput,
config: HeuristicConfig,
) -> tuple[Verdict, list[str]]:
"""Apply threshold logic to determine BUY / WATCH / SKIP verdict.
Returns the verdict and a list of reasoning strings explaining the
decision.
"""
reasoning: list[str] = []
valuation_score = normalized.valuation_score if normalized.valuation_score is not None else 0.0
earnings_days = normalized.earnings_proximity_days if normalized.earnings_proximity_days is not None else 0
# --- Check BUY conditions ---
buy_conditions = {
"confidence": confidence >= config.buy_confidence,
"s_total": s_total >= config.buy_s_total,
"valuation": valuation_score >= config.buy_valuation_min,
"macro_bias": normalized.macro_bias > config.macro_bias_threshold,
"earnings_proximity": earnings_days > config.earnings_days_threshold,
}
all_buy_met = all(buy_conditions.values())
if all_buy_met:
reasoning.append(
f"BUY: all conditions met — confidence={confidence:.3f} "
f"(>= {config.buy_confidence}), S_total={s_total:.3f} "
f"(>= {config.buy_s_total}), valuation={valuation_score:.2f} "
f"(>= {config.buy_valuation_min}), macro_bias={normalized.macro_bias:.2f} "
f"(> {config.macro_bias_threshold}), earnings_days={earnings_days} "
f"(> {config.earnings_days_threshold})"
)
return Verdict.BUY, reasoning
# --- Check WATCH conditions ---
if confidence >= config.watch_confidence:
# WATCH: confidence is sufficient but not all BUY conditions met
failed_conditions = [k for k, v in buy_conditions.items() if not v]
reasoning.append(
f"WATCH: confidence={confidence:.3f} (>= {config.watch_confidence}) "
f"but BUY conditions not fully met — failed: {', '.join(failed_conditions)}"
)
for cond_name, met in buy_conditions.items():
if not met:
reasoning.append(f" - {cond_name} not met")
return Verdict.WATCH, reasoning
# --- SKIP ---
reasoning.append(
f"SKIP: confidence={confidence:.3f} < {config.watch_confidence} "
f"(watch threshold)"
)
return Verdict.SKIP, reasoning
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_heuristic_pipeline(
normalized: NormalizedInput,
confluence_signals: list[ConfluenceSignal],
config: HeuristicConfig,
) -> HeuristicResult:
"""Run the deterministic heuristic pipeline.
Computes ``S_total = S_company + S_macro + S_competitive`` using the
existing three-layer signal aggregation model and produces a
confidence-gated BUY / WATCH / SKIP verdict.
Args:
normalized: The unified input structure for this evaluation tick.
confluence_signals: Signals that passed multi-timeframe confluence
filtering.
config: Heuristic pipeline thresholds.
Returns:
A :class:`HeuristicResult` with verdict, scores, weights, and
reasoning.
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7
"""
# 1. Compute three-layer scores
s_company, signal_weights = _compute_s_company(confluence_signals)
s_macro = _compute_s_macro(normalized)
s_competitive = _compute_s_competitive(confluence_signals)
s_total = s_company + s_macro + s_competitive
# 2. Compute confidence
confidence = _compute_confidence(confluence_signals)
# 3. Determine verdict
verdict, reasoning = _determine_verdict(confidence, s_total, normalized, config)
logger.info(
"Heuristic pipeline [%s]: verdict=%s confidence=%.3f "
"S_total=%.3f (company=%.3f macro=%.3f competitive=%.3f) "
"signals=%d",
normalized.ticker,
verdict.value,
confidence,
s_total,
s_company,
s_macro,
s_competitive,
len(confluence_signals),
)
return HeuristicResult(
verdict=verdict,
confidence=confidence,
s_total=s_total,
s_company=s_company,
s_macro=s_macro,
s_competitive=s_competitive,
signal_weights=signal_weights,
reasoning=reasoning,
)
+180
View File
@@ -0,0 +1,180 @@
"""Signal engine entry point — asyncio event loop and queue polling.
Connects to PostgreSQL and Redis, loads configuration from ``risk_configs``,
and polls the ``stonks:queue:signal_engine`` queue indefinitely. Each
queue message triggers a full evaluation tick via ``evaluate_tick()``.
When ``dual_pipeline_enabled`` is ``False`` the worker sleeps and retries
(fail-safe: the existing pipeline continues unchanged).
Requirements: 13.1, 13.6, 13.7, 16.1, 16.6
"""
from __future__ import annotations
import asyncio
import json
import logging
import sys
import asyncpg
import redis.asyncio
from services.shared.config import load_config as load_app_config
from services.shared.redis_keys import QUEUE_SIGNAL_ENGINE, queue_key
from services.signal_engine.config import load_config as load_signal_config
from services.signal_engine.worker import evaluate_tick
logger = logging.getLogger(__name__)
# BLPOP timeout in seconds — how long to wait for a queue message before
# looping back to check the enabled flag.
_BLPOP_TIMEOUT = 5
async def main() -> None:
"""Start the signal engine worker loop.
1. Connect to PostgreSQL (asyncpg pool) using env vars from
``services.shared.config``.
2. Connect to Redis (redis.asyncio) using env vars.
3. Load signal engine config via ``load_config(pool)``.
4. Log active configuration at startup.
5. Poll ``stonks:queue:signal_engine`` queue indefinitely (BLPOP).
6. Check ``dual_pipeline_enabled`` flag; if disabled, sleep and retry.
7. On config read failure, default to disabled (fail-safe).
8. Parse queue message as JSON: ``{"ticker": "AAPL", "triggered_at": "..."}``.
9. Call ``evaluate_tick(pool, redis, ticker, config)`` for each message.
Requirements: 13.1, 13.6, 13.7, 16.1, 16.6
"""
# --- Setup logging ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
stream=sys.stdout,
)
logger.info("Signal engine starting up")
# --- Load shared app config for connection details ---
app_config = load_app_config()
# --- Connect to PostgreSQL ---
pool = await asyncpg.create_pool(
dsn=app_config.postgres.dsn,
min_size=2,
max_size=10,
)
logger.info("Connected to PostgreSQL at %s", app_config.postgres.host)
# --- Connect to Redis ---
redis_client = redis.asyncio.from_url(
app_config.redis.url,
decode_responses=True,
)
logger.info("Connected to Redis at %s", app_config.redis.host)
# --- Load signal engine config ---
try:
config = await load_signal_config(pool)
except Exception:
logger.warning(
"Failed to load signal engine config at startup — "
"defaulting to disabled (fail-safe)",
exc_info=True,
)
from services.signal_engine.config import SignalEngineConfig
config = SignalEngineConfig() # dual_pipeline_enabled=False
logger.info(
"Signal engine config: dual_pipeline_enabled=%s, "
"heuristic=%s, probabilistic=%s, shadow_mode=%s, "
"polling_interval=%ds",
config.dual_pipeline_enabled,
config.heuristic_pipeline_enabled,
config.probabilistic_pipeline_enabled,
config.shadow_mode,
config.polling_interval_seconds,
)
# --- Queue key ---
signal_queue = queue_key(QUEUE_SIGNAL_ENGINE)
logger.info("Polling queue: %s", signal_queue)
# --- Main loop ---
try:
while True:
# Check if dual pipeline is enabled
if not config.dual_pipeline_enabled:
logger.debug(
"Dual pipeline disabled — sleeping %ds before retry",
config.polling_interval_seconds,
)
await asyncio.sleep(config.polling_interval_seconds)
# Reload config to pick up flag changes
try:
config = await load_signal_config(pool)
except Exception:
logger.warning(
"Failed to reload signal engine config — "
"keeping disabled (fail-safe)",
exc_info=True,
)
continue
# BLPOP: blocking pop from the signal engine queue
try:
result = await redis_client.blpop(
signal_queue,
timeout=_BLPOP_TIMEOUT,
)
except Exception:
logger.warning(
"Redis BLPOP failed — sleeping before retry",
exc_info=True,
)
await asyncio.sleep(5)
continue
if result is None:
# Timeout — no message, loop back
continue
# result is (queue_name, message)
_, raw_message = result
# Parse the queue message
try:
message = json.loads(raw_message)
ticker = message["ticker"]
except (json.JSONDecodeError, KeyError, TypeError):
logger.warning(
"Invalid queue message — skipping: %s",
raw_message,
)
continue
logger.info("Processing evaluation tick for %s", ticker)
# Run the evaluation tick
try:
await evaluate_tick(pool, redis_client, ticker, config)
except Exception:
logger.error(
"Unhandled error in evaluate_tick for %s",
ticker,
exc_info=True,
)
except KeyboardInterrupt:
logger.info("Signal engine shutting down (KeyboardInterrupt)")
finally:
await pool.close()
await redis_client.aclose()
logger.info("Signal engine shut down")
if __name__ == "__main__":
asyncio.run(main())
+271
View File
@@ -0,0 +1,271 @@
"""Pydantic data models for the dual-pipeline signal engine.
Defines all input, intermediate, and output models consumed by the heuristic
pipeline, probabilistic pipeline, delta analyzer, exit engine, and output
formatter. Every model is a Pydantic ``BaseModel`` subclass with field-level
constraints where applicable.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Market data
# ---------------------------------------------------------------------------
class OHLCVBar(BaseModel):
"""Single OHLCV bar for a timeframe."""
timestamp: datetime
open: float
high: float
low: float
close: float
volume: float
# ---------------------------------------------------------------------------
# Position state (for exit engine)
# ---------------------------------------------------------------------------
class OpenPositionState(BaseModel):
"""Snapshot of an open position for exit evaluation."""
position_id: str
ticker: str
entry_price: float
current_price: float
stop_loss: float
target_1: float
target_2: float
trailing_stop: float | None = None
partial_exit_done: bool = False
atr: float | None = None
# ---------------------------------------------------------------------------
# Normalized input consumed by both pipelines
# ---------------------------------------------------------------------------
class NormalizedInput(BaseModel):
"""Unified input structure consumed by both pipelines."""
ticker: str
evaluated_at: datetime
# Multi-timeframe OHLCV bars keyed by timeframe label
bars: dict[str, list[OHLCVBar]] # {"M30": [...], "H1": [...], ...}
# Fundamental / macro context
valuation_score: float | None = None # [0.0, 1.0]
earnings_proximity_days: int | None = None
macro_bias: float = 0.0 # [-1.0, 1.0]
# Open positions for exit evaluation
open_positions: list[OpenPositionState] = Field(default_factory=list)
# Price series helpers (used by probabilistic pipeline)
closing_prices: list[float] = Field(default_factory=list)
returns: list[float] = Field(default_factory=list)
current_price: float | None = None
# ---------------------------------------------------------------------------
# Signal evaluation primitives
# ---------------------------------------------------------------------------
class SignalDirection(str, Enum):
BULLISH = "bullish"
BEARISH = "bearish"
NEUTRAL = "neutral"
class SignalResult(BaseModel):
"""Output from a single signal evaluator on a single timeframe."""
signal_type: str
timeframe: str
strength: float = Field(ge=0.0, le=1.0)
direction: SignalDirection
confidence: float = Field(ge=0.0, le=1.0)
metadata: dict = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Multi-timeframe confluence
# ---------------------------------------------------------------------------
class ConfluenceSignal(BaseModel):
"""A signal that passed multi-timeframe confluence filtering."""
signal_type: str
direction: SignalDirection
confluence_score: float
active_timeframes: list[str]
per_timeframe: dict[str, float]
# ---------------------------------------------------------------------------
# Pipeline verdicts
# ---------------------------------------------------------------------------
class Verdict(str, Enum):
BUY = "BUY"
WATCH = "WATCH"
SKIP = "SKIP"
# ---------------------------------------------------------------------------
# Heuristic pipeline output
# ---------------------------------------------------------------------------
class HeuristicResult(BaseModel):
"""Output from the heuristic (deterministic) pipeline."""
verdict: Verdict
confidence: float = Field(ge=0.0, le=1.0)
s_total: float
s_company: float
s_macro: float
s_competitive: float
signal_weights: list[dict] = Field(default_factory=list)
reasoning: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Probabilistic pipeline output
# ---------------------------------------------------------------------------
class LikelihoodRatio(BaseModel):
"""A single signal's likelihood ratio for Bayesian updating."""
signal_type: str
cluster: str
lr: float
log_lr: float
penalized_log_lr: float
hit_rate: float
strength: float
class ProbabilisticResult(BaseModel):
"""Output from the probabilistic (Bayesian) pipeline."""
verdict: Verdict
p_up: float = Field(ge=0.0, le=1.0)
entropy: float = Field(ge=0.0, le=1.0)
ev_r: float
prior: float
posterior: float
likelihood_ratios: list[LikelihoodRatio] = Field(default_factory=list)
regime: str
reasoning: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Delta analyzer output
# ---------------------------------------------------------------------------
class DeltaResult(BaseModel):
"""Output from the delta analyzer comparing both pipelines."""
agreement: bool
confidence_delta: float
heuristic_verdict: str
probabilistic_verdict: str
disagreement_reasons: list[str] = Field(default_factory=list)
rolling_agreement_rate: float | None = None
# ---------------------------------------------------------------------------
# Exit engine
# ---------------------------------------------------------------------------
class ExitType(str, Enum):
EXIT_HALF = "EXIT_HALF"
EXIT_FULL = "EXIT_FULL"
class ExitSignal(BaseModel):
"""An exit signal for an open position."""
position_id: str
ticker: str
exit_type: ExitType
reason: str
price: float
# ---------------------------------------------------------------------------
# Trade plan
# ---------------------------------------------------------------------------
class TradePlan(BaseModel):
"""Optional trade plan attached to a BUY signal."""
entry_price: float
stop_loss: float
target_1: float
target_2: float
position_size_pct: float = Field(ge=0.0, le=1.0)
max_loss_pct: float = Field(ge=0.0, le=1.0)
dual_confirmed: bool = False
probabilistic_only: bool = False
# ---------------------------------------------------------------------------
# Structured output contract
# ---------------------------------------------------------------------------
class SignalOutput(BaseModel):
"""The structured output contract consumed by the trading engine and audit systems."""
output_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
ticker: str
timestamp: datetime
price: float
# Heuristic pipeline section
heuristic_verdict: str
heuristic_confidence: float
heuristic_s_total: float
# Probabilistic pipeline section
probabilistic_verdict: str
probabilistic_p_up: float
probabilistic_entropy: float
probabilistic_ev_r: float
# Delta analysis section
delta_agreement: bool
delta_confidence_delta: float
delta_reasons: list[str] = Field(default_factory=list)
# Optional trade plan and exit signals
trade_plan: TradePlan | None = None
exit_signals: list[ExitSignal] = Field(default_factory=list)
# Detail payloads for audit / dashboard
heuristic_detail: dict = Field(default_factory=dict)
probabilistic_detail: dict = Field(default_factory=dict)
# Pipeline mode metadata
pipeline_mode: str = "dual_pipeline"
shadow_mode: bool = False
+459
View File
@@ -0,0 +1,459 @@
"""Input Normalizer — fetches and assembles NormalizedInput for a single tick.
Queries multiple data sources (market snapshots, trend windows, earnings
calendar, macro impact records, position stop levels) and assembles them
into a single ``NormalizedInput`` consumed by both pipelines.
Missing data sources produce sentinel values (``None`` / empty list) with a
logged warning — the normalizer never crashes on unavailable data.
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
import asyncpg
from .config import SignalEngineConfig
from .models import NormalizedInput, OHLCVBar, OpenPositionState
logger = logging.getLogger(__name__)
# Timeframes the signal engine evaluates, ordered shortest → longest.
TIMEFRAMES = ("M30", "H1", "H4", "D", "W", "M")
# ---------------------------------------------------------------------------
# Direction → numeric bias mapping (same semantics as aggregation worker)
# ---------------------------------------------------------------------------
_DIRECTION_TO_BIAS: dict[str, float] = {
"positive": 1.0,
"negative": -1.0,
"mixed": 0.0,
"neutral": 0.0,
}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _validate_monotonic_timestamps(
bars: list[OHLCVBar],
timeframe: str,
ticker: str,
) -> list[OHLCVBar]:
"""Return *bars* sorted by timestamp, warning on non-monotonic input.
If timestamps are already strictly increasing the list is returned
unchanged. Otherwise the bars are sorted and a warning is logged.
"""
if len(bars) <= 1:
return bars
is_monotonic = all(
bars[i].timestamp < bars[i + 1].timestamp for i in range(len(bars) - 1)
)
if is_monotonic:
return bars
logger.warning(
"%s/%s: OHLCV timestamps not monotonically increasing — sorting",
ticker,
timeframe,
)
return sorted(bars, key=lambda b: b.timestamp)
def _polygon_bar_to_ohlcv(row: asyncpg.Record) -> OHLCVBar | None:
"""Convert a market_snapshots row (JSONB data column) to an OHLCVBar.
Polygon bar format stored in ``data``:
t — timestamp in epoch milliseconds
o — open
h — high
l — low
c — close
v — volume
Returns ``None`` if the row cannot be parsed.
"""
data = row["data"]
if not isinstance(data, dict):
return None
try:
ts_ms = data.get("t")
if ts_ms is None:
return None
return OHLCVBar(
timestamp=datetime.fromtimestamp(int(ts_ms) / 1000, tz=timezone.utc),
open=float(data.get("o", 0)),
high=float(data.get("h", 0)),
low=float(data.get("l", 0)),
close=float(data.get("c", 0)),
volume=float(data.get("v", 0)),
)
except (TypeError, ValueError, OverflowError):
return None
# ---------------------------------------------------------------------------
# Data-source fetchers
# ---------------------------------------------------------------------------
async def _fetch_bars(
pool: asyncpg.Pool,
ticker: str,
) -> dict[str, list[OHLCVBar]]:
"""Fetch OHLCV bars from ``market_snapshots`` for all timeframes.
The current database stores daily bars (``snapshot_type = 'bar'``) from
Polygon. Intraday bars are stored with ``snapshot_type = 'intraday_bar'``
when available.
For timeframes that have no dedicated data yet (H4, W, M) we derive them
from daily bars where possible:
- **W** (weekly): group daily bars by ISO week.
- **M** (monthly): group daily bars by calendar month.
- **H4 / H1 / M30**: sourced from intraday snapshots when present;
otherwise left empty.
Returns a dict keyed by timeframe label with validated bar lists.
"""
bars: dict[str, list[OHLCVBar]] = {tf: [] for tf in TIMEFRAMES}
# --- Daily bars --------------------------------------------------------
try:
rows = await pool.fetch(
"SELECT data FROM market_snapshots "
"WHERE ticker = $1 AND snapshot_type = 'bar' "
"ORDER BY captured_at ASC",
ticker,
)
daily: list[OHLCVBar] = []
for row in rows:
bar = _polygon_bar_to_ohlcv(row)
if bar is not None:
daily.append(bar)
bars["D"] = daily
except Exception:
logger.warning("%s: failed to fetch daily bars", ticker, exc_info=True)
# --- Intraday bars (M30, H1) ------------------------------------------
try:
intraday_rows = await pool.fetch(
"SELECT data FROM market_snapshots "
"WHERE ticker = $1 AND snapshot_type = 'intraday_bar' "
"ORDER BY captured_at ASC",
ticker,
)
intraday: list[OHLCVBar] = []
for row in intraday_rows:
bar = _polygon_bar_to_ohlcv(row)
if bar is not None:
intraday.append(bar)
# Assign intraday bars to M30 and H1 buckets.
# The actual timespan depends on the source config; we store them
# under M30 (shortest) and duplicate to H1 for now. When dedicated
# H1 bars are ingested they will replace this.
if intraday:
bars["M30"] = intraday
bars["H1"] = intraday
except Exception:
logger.warning("%s: failed to fetch intraday bars", ticker, exc_info=True)
# --- Derive H4 from intraday (4-hour grouping) ------------------------
# Left empty when no intraday data — sentinel value per Req 1.3.
# --- Derive weekly bars from daily ------------------------------------
if bars["D"]:
bars["W"] = _aggregate_bars_by_period(bars["D"], period="week")
# --- Derive monthly bars from daily -----------------------------------
if bars["D"]:
bars["M"] = _aggregate_bars_by_period(bars["D"], period="month")
return bars
def _aggregate_bars_by_period(
daily_bars: list[OHLCVBar],
period: str,
) -> list[OHLCVBar]:
"""Aggregate daily bars into weekly or monthly bars.
Groups by ISO week (period="week") or calendar month (period="month"),
then computes OHLCV aggregates per group.
"""
from collections import OrderedDict
groups: OrderedDict[tuple[int, int], list[OHLCVBar]] = OrderedDict()
for bar in daily_bars:
if period == "week":
iso = bar.timestamp.isocalendar()
key = (iso[0], iso[1]) # (year, week)
else:
key = (bar.timestamp.year, bar.timestamp.month)
groups.setdefault(key, []).append(bar)
result: list[OHLCVBar] = []
for group_bars in groups.values():
if not group_bars:
continue
result.append(
OHLCVBar(
timestamp=group_bars[0].timestamp, # period open timestamp
open=group_bars[0].open,
high=max(b.high for b in group_bars),
low=min(b.low for b in group_bars),
close=group_bars[-1].close,
volume=sum(b.volume for b in group_bars),
)
)
return result
async def _fetch_fundamentals(
pool: asyncpg.Pool,
ticker: str,
) -> tuple[float | None, int | None]:
"""Fetch valuation_score and earnings_proximity_days.
- **valuation_score**: derived from the latest ``trend_windows`` confidence
for the ticker (entity_type='company', entity_id=ticker).
- **earnings_proximity_days**: days until the next earnings date from
``earnings_calendar``.
Returns ``(valuation_score, earnings_proximity_days)`` with ``None``
sentinels for unavailable data.
"""
valuation_score: float | None = None
earnings_proximity_days: int | None = None
# --- Valuation score from trend_windows --------------------------------
try:
row = await pool.fetchrow(
"SELECT confidence FROM trend_windows "
"WHERE entity_type = 'company' AND entity_id = $1 "
"ORDER BY generated_at DESC LIMIT 1",
ticker,
)
if row is not None:
valuation_score = float(row["confidence"])
else:
logger.warning("%s: no trend_windows data — valuation_score=None", ticker)
except Exception:
logger.warning(
"%s: failed to fetch valuation_score", ticker, exc_info=True
)
# --- Earnings proximity from earnings_calendar -------------------------
try:
row = await pool.fetchrow(
"SELECT earnings_date FROM earnings_calendar "
"WHERE ticker = $1 AND earnings_date >= CURRENT_DATE "
"ORDER BY earnings_date ASC LIMIT 1",
ticker,
)
if row is not None:
delta = row["earnings_date"] - datetime.now(timezone.utc).date()
earnings_proximity_days = delta.days
else:
logger.warning(
"%s: no upcoming earnings in calendar — earnings_proximity_days=None",
ticker,
)
except Exception:
logger.warning(
"%s: failed to fetch earnings_proximity_days", ticker, exc_info=True
)
return valuation_score, earnings_proximity_days
async def _fetch_macro_bias(
pool: asyncpg.Pool,
ticker: str,
) -> float:
"""Compute macro_bias for *ticker* from recent ``macro_impact_records``.
Averages the numeric bias of the most recent impact records (up to 10)
weighted by their confidence. The direction string is mapped to a float
via ``_DIRECTION_TO_BIAS``.
Returns 0.0 (neutral) when no records are found or on error.
"""
try:
rows = await pool.fetch(
"SELECT impact_direction, macro_impact_score, confidence "
"FROM macro_impact_records "
"WHERE ticker = $1 "
"ORDER BY computed_at DESC LIMIT 10",
ticker,
)
if not rows:
logger.warning("%s: no macro_impact_records — macro_bias=0.0", ticker)
return 0.0
weighted_sum = 0.0
weight_total = 0.0
for row in rows:
direction = row["impact_direction"] or "neutral"
bias = _DIRECTION_TO_BIAS.get(direction, 0.0)
score = float(row["macro_impact_score"] or 0.0)
conf = float(row["confidence"] or 0.5)
w = score * conf
weighted_sum += bias * w
weight_total += w
if weight_total == 0.0:
return 0.0
# Clamp to [-1.0, 1.0]
raw = weighted_sum / weight_total
return max(-1.0, min(1.0, raw))
except Exception:
logger.warning("%s: failed to fetch macro_bias", ticker, exc_info=True)
return 0.0
async def _fetch_open_positions(
pool: asyncpg.Pool,
ticker: str,
) -> list[OpenPositionState]:
"""Fetch open positions for *ticker* from ``position_stop_levels``.
Joins with ``positions`` for current_price when available.
Returns an empty list on error or when no positions exist.
"""
try:
rows = await pool.fetch(
"SELECT psl.id, psl.ticker, psl.entry_price, "
" psl.stop_loss_price, psl.take_profit_price, "
" psl.trailing_stop_active, psl.atr_value, "
" psl.atr_multiplier, psl.reward_risk_ratio, "
" COALESCE(p.current_price, psl.entry_price) AS current_price "
"FROM position_stop_levels psl "
"LEFT JOIN positions p ON p.ticker = psl.ticker "
"WHERE psl.ticker = $1 AND psl.active = TRUE",
ticker,
)
positions: list[OpenPositionState] = []
for row in rows:
entry = float(row["entry_price"])
current = float(row["current_price"])
stop = float(row["stop_loss_price"])
tp = float(row["take_profit_price"])
atr = float(row["atr_value"]) if row["atr_value"] else None
rr = float(row["reward_risk_ratio"]) if row["reward_risk_ratio"] else 2.0
# Derive target_2 from reward-risk ratio if only one TP level
target_1 = tp
target_2 = entry + (tp - entry) * rr if rr > 1.0 else tp
positions.append(
OpenPositionState(
position_id=str(row["id"]),
ticker=row["ticker"],
entry_price=entry,
current_price=current,
stop_loss=stop,
target_1=target_1,
target_2=target_2,
trailing_stop=None, # computed by exit engine at runtime
partial_exit_done=bool(row["trailing_stop_active"]),
atr=atr,
)
)
return positions
except Exception:
logger.warning(
"%s: failed to fetch open positions", ticker, exc_info=True
)
return []
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def normalize_input(
pool: asyncpg.Pool,
ticker: str,
config: SignalEngineConfig,
) -> NormalizedInput:
"""Fetch and assemble all data needed for a single evaluation tick.
Sources:
- OHLCV bars from ``market_snapshots`` (M30, H1, H4, D, W, M)
- Fundamental metrics from ``trend_windows`` + ``earnings_calendar``
- Macro context from ``macro_impact_records``
- Open position state from ``position_stop_levels`` + ``positions``
Missing data sources produce sentinel values (``None`` / empty list)
with a logged warning. The function never raises — it always returns
a valid ``NormalizedInput``.
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
"""
now = datetime.now(timezone.utc)
# Fetch all data sources concurrently for efficiency.
# Each fetcher handles its own errors and returns sentinels on failure.
bars_task = asyncio.create_task(_fetch_bars(pool, ticker))
fundamentals_task = asyncio.create_task(_fetch_fundamentals(pool, ticker))
macro_task = asyncio.create_task(_fetch_macro_bias(pool, ticker))
positions_task = asyncio.create_task(_fetch_open_positions(pool, ticker))
bars = await bars_task
valuation_score, earnings_proximity_days = await fundamentals_task
macro_bias = await macro_task
open_positions = await positions_task
# Validate monotonic timestamps within each timeframe (Req 1.4)
for tf in TIMEFRAMES:
bars[tf] = _validate_monotonic_timestamps(bars[tf], tf, ticker)
# Compute closing_prices and returns from daily bars for regime
# classification (used by the probabilistic pipeline).
closing_prices: list[float] = []
returns: list[float] = []
daily = bars.get("D", [])
if daily:
closing_prices = [bar.close for bar in daily]
if len(closing_prices) >= 2:
returns = [
(closing_prices[i] - closing_prices[i - 1]) / closing_prices[i - 1]
if closing_prices[i - 1] != 0
else 0.0
for i in range(1, len(closing_prices))
]
# Determine current_price from the latest close of the shortest
# available timeframe.
current_price: float | None = None
for tf in TIMEFRAMES: # shortest first
if bars[tf]:
current_price = bars[tf][-1].close
break
return NormalizedInput(
ticker=ticker,
evaluated_at=now,
bars=bars,
valuation_score=valuation_score,
earnings_proximity_days=earnings_proximity_days,
macro_bias=macro_bias,
open_positions=open_positions,
closing_prices=closing_prices,
returns=returns,
current_price=current_price,
)
+107
View File
@@ -0,0 +1,107 @@
"""Database persistence for signal engine outputs.
Persists ``SignalOutput`` instances to the ``signal_engine_outputs`` table.
Persistence failures are logged and swallowed — they never block signal
emission to the trading queue.
Requirements: 15.1, 15.4
"""
from __future__ import annotations
import json
import logging
import asyncpg
from services.signal_engine.models import SignalOutput
logger = logging.getLogger(__name__)
# INSERT statement for the signal_engine_outputs table.
_INSERT_SQL = """
INSERT INTO signal_engine_outputs (
id,
ticker,
evaluated_at,
price,
heuristic_verdict,
heuristic_confidence,
heuristic_s_total,
probabilistic_verdict,
probabilistic_p_up,
probabilistic_entropy,
probabilistic_ev_r,
delta_agreement,
delta_confidence_delta,
delta_reasons,
trade_plan,
full_output,
exit_signals,
pipeline_mode,
shadow_mode
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19
)
"""
async def persist_signal_output(
pool: asyncpg.Pool,
output: SignalOutput,
) -> None:
"""Persist a SignalOutput to the signal_engine_outputs table.
Logs and continues on database errors (non-blocking).
Requirements: 15.1, 15.4
"""
try:
trade_plan_json: str | None = None
if output.trade_plan is not None:
trade_plan_json = json.dumps(output.trade_plan.model_dump())
exit_signals_json = json.dumps(
[e.model_dump() for e in output.exit_signals]
)
delta_reasons_json = json.dumps(output.delta_reasons)
full_output_json = output.model_dump_json()
await pool.execute(
_INSERT_SQL,
output.output_id, # $1 id
output.ticker, # $2 ticker
output.timestamp, # $3 evaluated_at
output.price, # $4 price
output.heuristic_verdict, # $5 heuristic_verdict
output.heuristic_confidence, # $6 heuristic_confidence
output.heuristic_s_total, # $7 heuristic_s_total
output.probabilistic_verdict, # $8 probabilistic_verdict
output.probabilistic_p_up, # $9 probabilistic_p_up
output.probabilistic_entropy, # $10 probabilistic_entropy
output.probabilistic_ev_r, # $11 probabilistic_ev_r
output.delta_agreement, # $12 delta_agreement
output.delta_confidence_delta, # $13 delta_confidence_delta
delta_reasons_json, # $14 delta_reasons (JSONB)
trade_plan_json, # $15 trade_plan (JSONB, nullable)
full_output_json, # $16 full_output (JSONB)
exit_signals_json, # $17 exit_signals (JSONB)
output.pipeline_mode, # $18 pipeline_mode
output.shadow_mode, # $19 shadow_mode
)
logger.debug(
"Persisted signal output %s for %s",
output.output_id,
output.ticker,
)
except Exception:
logger.error(
"Failed to persist signal output %s for %s — continuing",
output.output_id,
output.ticker,
exc_info=True,
)
+380
View File
@@ -0,0 +1,380 @@
"""Probabilistic Pipeline (Pipeline B) — Bayesian inference and verdict.
Computes a posterior probability via regime-based priors, likelihood ratio
accumulation with correlation penalty, entropy gating, and expected value
calculation. Produces a BUY / WATCH / SKIP verdict.
The pipeline reuses the existing ``classify_regime`` infrastructure from
``services.aggregation.regime`` for regime classification and wraps the
Bayesian math with signal-cluster correlation penalties from
``services.signal_engine.correlation``.
Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9,
14.1, 14.2, 14.3, 14.4, 14.5
"""
from __future__ import annotations
import logging
import math
from services.aggregation.regime import MarketRegime, RegimeClassification
from services.signal_engine.config import ProbabilisticConfig
from services.signal_engine.correlation import (
apply_correlation_penalty,
classify_signal,
)
from services.signal_engine.models import (
ConfluenceSignal,
LikelihoodRatio,
NormalizedInput,
ProbabilisticResult,
SignalDirection,
Verdict,
)
logger = logging.getLogger(__name__)
# Default hit rate used when no historical hit rate is available.
_DEFAULT_HIT_RATE: float = 0.6
# ---------------------------------------------------------------------------
# Regime → prior mapping
# ---------------------------------------------------------------------------
def _regime_to_prior(
regime: RegimeClassification,
config: ProbabilisticConfig,
) -> float:
"""Map a regime classification to a prior probability.
Mapping (Req 14.2):
- TREND_FOLLOWING with positive trend_indicator → bull prior (0.58)
- TREND_FOLLOWING with negative trend_indicator → bear prior (0.42)
- MEAN_REVERSION → range prior (0.50)
- PANIC → bear prior (0.42)
- UNCERTAINTY → range prior (0.50)
"""
if regime.regime == MarketRegime.TREND_FOLLOWING:
if regime.trend_indicator > 0:
return config.regime_prior_bull
return config.regime_prior_bear
if regime.regime == MarketRegime.MEAN_REVERSION:
return config.regime_prior_range
if regime.regime == MarketRegime.PANIC:
return config.regime_prior_bear
# UNCERTAINTY or any unknown → range prior
return config.regime_prior_range
# ---------------------------------------------------------------------------
# Likelihood ratio computation
# ---------------------------------------------------------------------------
def _compute_likelihood_ratios(
confluence_signals: list[ConfluenceSignal],
) -> list[LikelihoodRatio]:
"""Compute raw likelihood ratios for each confluence signal.
For each signal:
- h = hit rate (use confidence as proxy, default 0.6)
- s = signal strength (confluence_score)
- P(sig|up) = h * s + (1 - h) * (1 - s) * 0.5
- P(sig|down) = 1 - P(sig|up)
- LR = P(sig|up) / P(sig|down)
Direction-aware: bearish signals invert the LR (use 1/LR) so that
bearish evidence reduces P_up.
Requirements: 6.2
"""
ratios: list[LikelihoodRatio] = []
for sig in confluence_signals:
h = _DEFAULT_HIT_RATE
s = sig.confluence_score
# Clamp inputs to valid ranges to avoid numerical issues
h = max(0.01, min(h, 0.99))
s = max(0.01, min(s, 0.99))
p_sig_up = h * s + (1.0 - h) * (1.0 - s) * 0.5
p_sig_down = 1.0 - p_sig_up
# Guard against division by zero / near-zero
if p_sig_down < 1e-10:
p_sig_down = 1e-10
lr = p_sig_up / p_sig_down
# Bearish signals: invert the LR so it reduces P_up
if sig.direction == SignalDirection.BEARISH:
lr = 1.0 / lr if lr > 1e-10 else 1e10
log_lr = math.log(lr) if lr > 0 else 0.0
cluster = classify_signal(sig.signal_type)
ratios.append(
LikelihoodRatio(
signal_type=sig.signal_type,
cluster=cluster.value,
lr=lr,
log_lr=log_lr,
penalized_log_lr=log_lr, # will be updated by penalty
hit_rate=h,
strength=s,
)
)
return ratios
# ---------------------------------------------------------------------------
# Log-odds / sigmoid helpers
# ---------------------------------------------------------------------------
def _logit(p: float) -> float:
"""Compute logit(p) = log(p / (1 - p)).
Clamps p to (1e-10, 1 - 1e-10) to avoid infinities.
"""
p = max(1e-10, min(p, 1.0 - 1e-10))
return math.log(p / (1.0 - p))
def _sigmoid(x: float) -> float:
"""Compute sigmoid(x) = 1 / (1 + exp(-x)).
Clamps the exponent to avoid overflow.
"""
if x > 500:
return 1.0
if x < -500:
return 0.0
return 1.0 / (1.0 + math.exp(-x))
# ---------------------------------------------------------------------------
# Shannon entropy
# ---------------------------------------------------------------------------
def _shannon_entropy(p: float) -> float:
"""Compute Shannon entropy H = -p·log₂(p) - (1-p)·log₂(1-p).
Returns 0.0 at the boundaries (p = 0 or p = 1).
Result is in [0, 1] for binary entropy.
"""
if p <= 0.0 or p >= 1.0:
return 0.0
return -(p * math.log2(p) + (1.0 - p) * math.log2(1.0 - p))
# ---------------------------------------------------------------------------
# EV_R computation
# ---------------------------------------------------------------------------
def _compute_ev_r(
p_up: float,
confluence_signals: list[ConfluenceSignal],
) -> float:
"""Compute expected value per unit risk.
EV_R = P_up · E[win_R] - (1 - P_up) · 1.0
E[win_R] is estimated as the average confluence_score × 2.0
(heuristic for expected win in R-units). Falls back to 1.0 if
no signals are available.
"""
if confluence_signals:
avg_score = sum(s.confluence_score for s in confluence_signals) / len(
confluence_signals
)
e_win_r = avg_score * 2.0
else:
e_win_r = 1.0
return p_up * e_win_r - (1.0 - p_up) * 1.0
# ---------------------------------------------------------------------------
# Verdict logic
# ---------------------------------------------------------------------------
def _determine_verdict(
p_up: float,
entropy: float,
ev_r: float,
normalized: NormalizedInput,
config: ProbabilisticConfig,
) -> tuple[Verdict, list[str]]:
"""Apply threshold logic to determine BUY / WATCH / SKIP verdict.
Returns the verdict and a list of reasoning strings.
Requirements: 6.6, 6.7, 6.8
"""
reasoning: list[str] = []
valuation_score = (
normalized.valuation_score if normalized.valuation_score is not None else 0.0
)
# --- Entropy gating (Req 6.4) ---
if entropy > config.entropy_skip:
reasoning.append(
f"SKIP: entropy={entropy:.4f} > {config.entropy_skip} (high_entropy)"
)
return Verdict.SKIP, reasoning
# --- Check BUY conditions (Req 6.6) ---
buy_conditions = {
"p_up": p_up >= config.buy_p_up,
"entropy": entropy <= config.buy_entropy_max,
"ev_r": ev_r >= config.buy_ev_r_min,
"macro_bias": normalized.macro_bias > config.macro_bias_threshold,
"valuation": valuation_score >= config.buy_valuation_min,
}
all_buy_met = all(buy_conditions.values())
if all_buy_met:
reasoning.append(
f"BUY: all conditions met — P_up={p_up:.4f} "
f"(>= {config.buy_p_up}), entropy={entropy:.4f} "
f"(<= {config.buy_entropy_max}), EV_R={ev_r:.4f} "
f"(>= {config.buy_ev_r_min}), macro_bias={normalized.macro_bias:.2f} "
f"(> {config.macro_bias_threshold}), valuation={valuation_score:.2f} "
f"(>= {config.buy_valuation_min})"
)
return Verdict.BUY, reasoning
# --- Check WATCH conditions (Req 6.7) ---
watch_conditions = {
"p_up": p_up >= config.watch_p_up,
"entropy": entropy <= config.watch_entropy_max,
}
if all(watch_conditions.values()):
failed_buy = [k for k, v in buy_conditions.items() if not v]
reasoning.append(
f"WATCH: P_up={p_up:.4f} (>= {config.watch_p_up}), "
f"entropy={entropy:.4f} (<= {config.watch_entropy_max}) "
f"but BUY conditions not fully met — failed: {', '.join(failed_buy)}"
)
return Verdict.WATCH, reasoning
# --- SKIP (Req 6.8) ---
reasoning.append(
f"SKIP: P_up={p_up:.4f}, entropy={entropy:.4f}, EV_R={ev_r:.4f} "
f"— does not meet WATCH or BUY thresholds"
)
return Verdict.SKIP, reasoning
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_probabilistic_pipeline(
normalized: NormalizedInput,
confluence_signals: list[ConfluenceSignal],
regime: RegimeClassification,
config: ProbabilisticConfig,
) -> ProbabilisticResult:
"""Run the Bayesian probabilistic pipeline.
Steps:
1. Initialize regime-based prior (bull=0.58, range=0.50, bear=0.42)
2. Compute likelihood ratios per signal
3. Apply correlation penalty via ``apply_correlation_penalty()``
4. Accumulate via log-odds: logit(P_post) = logit(P_prior) + Σ log(LR_i)
5. Compute Shannon entropy and apply entropy gating
6. Compute EV_R = P_up · E[win_R] - (1 - P_up) · 1.0
7. Produce BUY / WATCH / SKIP verdict
Args:
normalized: The unified input structure for this evaluation tick.
confluence_signals: Signals that passed multi-timeframe confluence
filtering.
regime: The current market regime classification.
config: Probabilistic pipeline thresholds.
Returns:
A :class:`ProbabilisticResult` with verdict, posterior, entropy,
EV_R, likelihood ratios, and reasoning.
Requirements: 6.16.9, 14.114.5
"""
reasoning: list[str] = []
# 1. Regime-based prior (Req 6.1, 14.2)
prior = _regime_to_prior(regime, config)
reasoning.append(
f"Regime={regime.regime.value}, trend_indicator={regime.trend_indicator:.1f} "
f"→ prior={prior:.2f}"
)
# 2. Compute likelihood ratios (Req 6.2)
raw_lrs = _compute_likelihood_ratios(confluence_signals)
# 3. Apply correlation penalty (Req 7.17.4)
penalized_lrs = apply_correlation_penalty(raw_lrs)
# 4. Accumulate via log-odds (Req 6.3, 14.3)
logit_prior = _logit(prior)
sum_penalized_log_lr = sum(lr.penalized_log_lr for lr in penalized_lrs)
logit_posterior = logit_prior + sum_penalized_log_lr
p_up = _sigmoid(logit_posterior)
reasoning.append(
f"logit(prior)={logit_prior:.4f} + Σ penalized_log_lr={sum_penalized_log_lr:.4f} "
f"= logit(posterior)={logit_posterior:.4f} → P_up={p_up:.4f}"
)
# 5. Shannon entropy (Req 6.4)
entropy = _shannon_entropy(p_up)
reasoning.append(f"Shannon entropy H={entropy:.4f}")
# 6. EV_R (Req 6.5)
ev_r = _compute_ev_r(p_up, confluence_signals)
reasoning.append(f"EV_R={ev_r:.4f}")
# 7. Verdict (Req 6.6, 6.7, 6.8)
verdict, verdict_reasoning = _determine_verdict(
p_up, entropy, ev_r, normalized, config
)
reasoning.extend(verdict_reasoning)
logger.info(
"Probabilistic pipeline [%s]: verdict=%s P_up=%.4f "
"entropy=%.4f EV_R=%.4f prior=%.2f regime=%s signals=%d",
normalized.ticker,
verdict.value,
p_up,
entropy,
ev_r,
prior,
regime.regime.value,
len(confluence_signals),
)
return ProbabilisticResult(
verdict=verdict,
p_up=p_up,
entropy=entropy,
ev_r=ev_r,
prior=prior,
posterior=p_up,
likelihood_ratios=penalized_lrs,
regime=regime.regime.value,
reasoning=reasoning,
)
@@ -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
+300
View File
@@ -0,0 +1,300 @@
"""Top-level orchestrator for a single evaluation tick.
Coordinates input normalization, exit evaluation, hard filters, signal
evaluation, both pipelines (concurrent), delta analysis, output formatting,
persistence, and Redis queue publication.
Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6
"""
from __future__ import annotations
import asyncio
import logging
import time
import asyncpg
import redis.asyncio
from services.aggregation.regime import classify_regime
from services.signal_engine.config import SignalEngineConfig
from services.signal_engine.confluence import compute_confluence
from services.signal_engine.delta import analyze_delta
from services.signal_engine.exit_engine import evaluate_exits
from services.signal_engine.formatter import (
format_output,
signal_output_to_recommendation,
)
from services.signal_engine.hard_filter import evaluate_hard_filters
from services.signal_engine.heuristic import run_heuristic_pipeline
from services.signal_engine.models import (
HeuristicResult,
NormalizedInput,
ProbabilisticResult,
SignalOutput,
SignalResult,
Verdict,
)
from services.signal_engine.normalizer import normalize_input
from services.signal_engine.persistence import persist_signal_output
from services.signal_engine.probabilistic import run_probabilistic_pipeline
from services.signal_engine.signals.cup_handle import CupHandleEvaluator
from services.signal_engine.signals.elliott_wave import ElliottWaveEvaluator
from services.signal_engine.signals.fibonacci import FibonacciEvaluator
from services.signal_engine.signals.ma_stack import MAStackEvaluator
from services.signal_engine.signals.rsi import RSIEvaluator
logger = logging.getLogger(__name__)
# Redis queue for trading decisions
_TRADING_QUEUE = "stonks:queue:trading_decisions"
# All signal evaluators
_EVALUATORS = [
FibonacciEvaluator(),
MAStackEvaluator(),
RSIEvaluator(),
CupHandleEvaluator(),
ElliottWaveEvaluator(),
]
# Default SKIP results used when a pipeline fails
_SKIP_HEURISTIC = HeuristicResult(
verdict=Verdict.SKIP,
confidence=0.0,
s_total=0.0,
s_company=0.0,
s_macro=0.0,
s_competitive=0.0,
signal_weights=[],
reasoning=["pipeline_error: heuristic pipeline raised an exception"],
)
_SKIP_PROBABILISTIC = ProbabilisticResult(
verdict=Verdict.SKIP,
p_up=0.5,
entropy=1.0,
ev_r=0.0,
prior=0.5,
posterior=0.5,
likelihood_ratios=[],
regime="uncertainty",
reasoning=["pipeline_error: probabilistic pipeline raised an exception"],
)
def _evaluate_signals(
normalized: NormalizedInput,
) -> dict[str, dict[str, SignalResult]]:
"""Run all signal evaluators across all timeframes.
Returns ``{signal_type: {timeframe: SignalResult}}`` for signals that
fired. Signals that returned ``None`` (insufficient data or no trigger)
are omitted.
"""
from services.signal_engine.normalizer import TIMEFRAMES
results: dict[str, dict[str, SignalResult]] = {}
for evaluator in _EVALUATORS:
for tf in TIMEFRAMES:
bars = normalized.bars.get(tf, [])
if not bars:
continue
try:
result = evaluator.evaluate(bars, tf)
except Exception:
logger.warning(
"Signal evaluator %s failed on %s/%s",
type(evaluator).__name__,
normalized.ticker,
tf,
exc_info=True,
)
continue
if result is not None:
results.setdefault(result.signal_type, {})[tf] = result
return results
async def evaluate_tick(
pool: asyncpg.Pool,
redis_client: redis.asyncio.Redis,
ticker: str,
config: SignalEngineConfig,
) -> SignalOutput | None:
"""Run a full evaluation tick for a single ticker.
Steps:
1. Normalize inputs (single fetch, shared reference)
2. Evaluate exit conditions for open positions
3. Run hard filters (short-circuit if filtered)
4. Evaluate signals across timeframes via Signal Library
5. Compute confluence
6. Classify regime via existing ``classify_regime()``
7. Run both pipelines concurrently via ``asyncio.gather``
8. Compute delta analysis
9. Format output
10. Persist to database and publish to Redis queue
Returns ``None`` if the ticker is hard-filtered or both pipelines fail.
Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6
"""
tick_start = time.monotonic()
# Step 1: Normalize inputs
normalized = await normalize_input(pool, ticker, config)
# Step 2: Evaluate exit conditions (before pipelines — Req 8.6)
current_price = normalized.current_price or 0.0
exit_signals = evaluate_exits(
normalized.open_positions,
{ticker: current_price},
config.exit_config,
)
# Step 3: Hard filters
filter_result = evaluate_hard_filters(normalized, config.hard_filter_config)
if filter_result.filtered:
logger.info(
"Ticker %s hard-filtered: %s",
ticker,
", ".join(filter_result.reasons),
)
return None
# Step 4: Evaluate signals across timeframes
signal_results = _evaluate_signals(normalized)
# Step 5: Compute confluence
confluence_signals = compute_confluence(signal_results, config.timeframe_weights)
# Step 6: Classify regime
regime = classify_regime(normalized.closing_prices, normalized.returns)
# Step 7: Run both pipelines concurrently
heuristic_start = time.monotonic()
async def _run_heuristic() -> HeuristicResult:
return run_heuristic_pipeline(
normalized, confluence_signals, config.heuristic_config
)
async def _run_probabilistic() -> ProbabilisticResult:
return run_probabilistic_pipeline(
normalized, confluence_signals, regime, config.probabilistic_config
)
results = await asyncio.gather(
_run_heuristic(),
_run_probabilistic(),
return_exceptions=True,
)
pipeline_elapsed = time.monotonic() - heuristic_start
# Handle pipeline exceptions — SKIP verdict for failed pipeline
heuristic_result: HeuristicResult
probabilistic_result: ProbabilisticResult
if isinstance(results[0], BaseException):
logger.error(
"Heuristic pipeline failed for %s: %s",
ticker,
results[0],
exc_info=results[0],
)
heuristic_result = _SKIP_HEURISTIC
else:
heuristic_result = results[0]
if isinstance(results[1], BaseException):
logger.error(
"Probabilistic pipeline failed for %s: %s",
ticker,
results[1],
exc_info=results[1],
)
probabilistic_result = _SKIP_PROBABILISTIC
else:
probabilistic_result = results[1]
# If both pipelines failed, return None
if isinstance(results[0], BaseException) and isinstance(results[1], BaseException):
logger.error(
"Both pipelines failed for %s — skipping tick",
ticker,
)
return None
logger.info(
"Pipelines completed for %s in %.3fs — heuristic=%s, probabilistic=%s",
ticker,
pipeline_elapsed,
heuristic_result.verdict.value,
probabilistic_result.verdict.value,
)
# Step 8: Delta analysis
delta = await analyze_delta(
heuristic_result, probabilistic_result, redis_client, ticker
)
# Step 9: Format output
price = normalized.current_price or 0.0
output = format_output(
ticker,
price,
heuristic_result,
probabilistic_result,
delta,
exit_signals,
config,
)
# Step 10: Persist to database
await persist_signal_output(pool, output)
# Step 11: Publish to trading queue (only if at least one BUY and not shadow_mode)
has_buy = (
heuristic_result.verdict == Verdict.BUY
or probabilistic_result.verdict == Verdict.BUY
)
if has_buy and not config.shadow_mode:
try:
recommendation = signal_output_to_recommendation(output)
await redis_client.rpush(
_TRADING_QUEUE,
recommendation.model_dump_json(),
)
logger.info(
"Published trading recommendation for %s to %s",
ticker,
_TRADING_QUEUE,
)
except Exception:
logger.error(
"Failed to publish trading recommendation for %s",
ticker,
exc_info=True,
)
elif has_buy and config.shadow_mode:
logger.info(
"Shadow mode: BUY signal for %s persisted but not published to trading queue",
ticker,
)
# Log wall-clock execution time
tick_elapsed = time.monotonic() - tick_start
logger.info(
"Evaluation tick for %s completed in %.3fs",
ticker,
tick_elapsed,
)
return output