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