Files
stonks-oracle/services/recommendation/eligibility.py
T

758 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministic recommendation eligibility logic.
Evaluates trend summaries against configurable thresholds to decide:
- Whether a recommendation should be generated at all
- What action type (buy/sell/hold/watch) is appropriate
- What execution mode (informational/paper_eligible/live_eligible) is allowed
- Position sizing guidance based on portfolio rules
All decisions are rule-based with no model involvement. The LLM is only
used downstream for optional thesis wording (a separate task).
Requirements: 7.1, 7.2, 7.3, 7.4, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6
v3 additions:
- ReturnDistribution and EV gate (Requirement 12)
- Regime-aware eligibility and mode escalation (Requirements 13.113.5)
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from enum import Enum
from services.shared.schemas import (
ActionType,
PositionSizing,
RecommendationMode,
TrendDirection,
TrendSummary,
)
class RejectionReason(str, Enum):
"""Why a trend summary was deemed ineligible for a recommendation."""
LOW_CONFIDENCE = "low_confidence"
LOW_TREND_STRENGTH = "low_trend_strength"
HIGH_CONTRADICTION = "high_contradiction"
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
NEUTRAL_DIRECTION = "neutral_direction"
@dataclass(frozen=True)
class EligibilityConfig:
"""Tunable thresholds for recommendation eligibility.
All thresholds are deterministic — no model inference involved.
"""
# --- Gate thresholds (below these → no recommendation) ---
min_confidence: float = 0.35
min_trend_strength: float = 0.10
max_contradiction_score: float = 0.60
min_evidence_count: int = 2 # combined supporting + opposing
# --- Action mapping thresholds ---
# Trend strength above this → buy/sell; below → hold/watch
action_strength_threshold: float = 0.25
# Confidence above this → hold (rather than watch) for weak signals
hold_confidence_threshold: float = 0.50
# --- Mode escalation thresholds ---
# Confidence required for paper_eligible (below → informational)
paper_confidence_threshold: float = 0.50
# Confidence required for live_eligible (below → paper at most)
live_confidence_threshold: float = 0.70
# Contradiction must be below this for live eligibility
live_max_contradiction: float = 0.25
# Minimum evidence count for live eligibility
live_min_evidence: int = 5
# --- Position sizing rules (Requirement 7.3) ---
# Base portfolio allocation percentage
base_portfolio_pct: float = 0.01
# Maximum portfolio allocation percentage
max_portfolio_pct: float = 0.10
# Base max loss percentage
base_max_loss_pct: float = 0.003
# Maximum max loss percentage
max_max_loss_pct: float = 0.02
# Confidence scaling: higher confidence → larger position (linear)
confidence_sizing_weight: float = 0.8
# Contradiction penalty: higher contradiction → smaller position
contradiction_sizing_penalty: float = 0.5
# --- Expected value gate (Requirement 14) ---
# EV threshold: minimum expected value to allow recommendation through
ev_threshold: float = 0.005
DEFAULT_ELIGIBILITY_CONFIG = EligibilityConfig()
@dataclass
class EligibilityResult:
"""Output of the deterministic eligibility evaluation.
Captures the decision, the reasoning, and all inputs used so the
full decision trace is reproducible (Requirement 8.3).
"""
eligible: bool
action: ActionType
mode: RecommendationMode
position_sizing: PositionSizing
rejection_reasons: list[RejectionReason] = field(default_factory=list)
time_horizon: str = ""
invalidation_conditions: list[str] = field(default_factory=list)
# Probabilistic pipeline fields (Req 14.5, 16.2)
ev_value: float | None = None
p_bull: float | None = None
pipeline_mode: str = "heuristic"
# ---------------------------------------------------------------------------
# Gate checks
# ---------------------------------------------------------------------------
def _check_gates(
summary: TrendSummary,
config: EligibilityConfig,
) -> list[RejectionReason]:
"""Apply hard gate checks. Returns a list of rejection reasons (empty = pass)."""
reasons: list[RejectionReason] = []
if summary.confidence < config.min_confidence:
reasons.append(RejectionReason.LOW_CONFIDENCE)
if summary.trend_strength < config.min_trend_strength:
reasons.append(RejectionReason.LOW_TREND_STRENGTH)
if summary.contradiction_score > config.max_contradiction_score:
reasons.append(RejectionReason.HIGH_CONTRADICTION)
evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
if evidence_count < config.min_evidence_count:
reasons.append(RejectionReason.INSUFFICIENT_EVIDENCE)
if summary.trend_direction == TrendDirection.NEUTRAL:
reasons.append(RejectionReason.NEUTRAL_DIRECTION)
return reasons
# ---------------------------------------------------------------------------
# Action mapping
# ---------------------------------------------------------------------------
def _determine_action(
summary: TrendSummary,
config: EligibilityConfig,
) -> ActionType:
"""Map trend direction and strength to an action type.
Strong bullish → BUY, strong bearish → SELL.
Weak but directional → HOLD if confidence is decent, else WATCH.
Mixed → WATCH.
"""
direction = summary.trend_direction
strength = summary.trend_strength
if direction == TrendDirection.MIXED:
return ActionType.WATCH
if direction == TrendDirection.NEUTRAL:
return ActionType.WATCH
strong_signal = strength >= config.action_strength_threshold
if direction == TrendDirection.BULLISH:
if strong_signal:
return ActionType.BUY
return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH
if direction == TrendDirection.BEARISH:
if strong_signal:
return ActionType.SELL
return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH
return ActionType.WATCH
# ---------------------------------------------------------------------------
# Mode escalation
# ---------------------------------------------------------------------------
def _determine_mode(
summary: TrendSummary,
action: ActionType,
config: EligibilityConfig,
) -> RecommendationMode:
"""Determine the highest execution mode allowed.
WATCH and HOLD actions are always informational — they don't trigger trades.
BUY/SELL can escalate to paper_eligible or live_eligible based on
confidence, contradiction, and evidence thresholds.
"""
if action in (ActionType.WATCH, ActionType.HOLD):
return RecommendationMode.INFORMATIONAL
evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
# Check live eligibility first (strictest)
if (
summary.confidence >= config.live_confidence_threshold
and summary.contradiction_score <= config.live_max_contradiction
and evidence_count >= config.live_min_evidence
):
return RecommendationMode.LIVE_ELIGIBLE
# Check paper eligibility
if summary.confidence >= config.paper_confidence_threshold:
return RecommendationMode.PAPER_ELIGIBLE
return RecommendationMode.INFORMATIONAL
# ---------------------------------------------------------------------------
# Position sizing (Requirement 7.3)
# ---------------------------------------------------------------------------
def _compute_position_sizing(
summary: TrendSummary,
config: EligibilityConfig,
) -> PositionSizing:
"""Compute position sizing guidance from portfolio rules and signal quality.
Higher confidence and trend strength → larger allocation (up to max).
Higher contradiction → smaller allocation (penalty).
Low evidence count further reduces allocation.
"""
# Confidence-based scaling over the full range
confidence_factor = config.confidence_sizing_weight * summary.confidence
# Trend strength multiplier — stronger trends justify larger positions
strength_factor = 0.5 + 0.5 * summary.trend_strength # range [0.5, 1.0]
portfolio_range = config.max_portfolio_pct - config.base_portfolio_pct
raw_portfolio = config.base_portfolio_pct + confidence_factor * strength_factor * portfolio_range
# Apply contradiction penalty
contradiction_penalty = config.contradiction_sizing_penalty * summary.contradiction_score
portfolio_pct = raw_portfolio * (1.0 - contradiction_penalty)
# Evidence count penalty — fewer sources = less confidence in sizing
evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
if evidence_count < 3:
portfolio_pct *= 0.5
elif evidence_count < 5:
portfolio_pct *= 0.75
# Clamp to bounds
portfolio_pct = max(config.base_portfolio_pct * 0.5, min(portfolio_pct, config.max_portfolio_pct))
# Max loss scales similarly
loss_range = config.max_max_loss_pct - config.base_max_loss_pct
raw_loss = config.base_max_loss_pct + confidence_factor * strength_factor * loss_range
max_loss_pct = raw_loss * (1.0 - contradiction_penalty)
if evidence_count < 3:
max_loss_pct *= 0.5
elif evidence_count < 5:
max_loss_pct *= 0.75
max_loss_pct = max(config.base_max_loss_pct * 0.5, min(max_loss_pct, config.max_max_loss_pct))
return PositionSizing(
portfolio_pct=round(portfolio_pct, 6),
max_loss_pct=round(max_loss_pct, 6),
)
# ---------------------------------------------------------------------------
# Time horizon mapping
# ---------------------------------------------------------------------------
_WINDOW_TO_HORIZON: dict[str, str] = {
"intraday": "intraday",
"1d": "swing_1d_3d",
"7d": "swing_1d_10d",
"30d": "position_10d_30d",
"90d": "position_30d_90d",
}
def _map_time_horizon(window: str) -> str:
"""Map a trend window to a human-readable time horizon label."""
return _WINDOW_TO_HORIZON.get(window, f"window_{window}")
# ---------------------------------------------------------------------------
# Invalidation conditions
# ---------------------------------------------------------------------------
def _derive_invalidation_conditions(
summary: TrendSummary,
action: ActionType,
) -> list[str]:
"""Generate deterministic invalidation conditions for the recommendation.
These describe when the recommendation should be considered stale or wrong.
"""
conditions: list[str] = []
if action == ActionType.BUY:
conditions.append(
f"Trend direction for {summary.entity_id} reverses to bearish"
)
elif action == ActionType.SELL:
conditions.append(
f"Trend direction for {summary.entity_id} reverses to bullish"
)
if summary.contradiction_score > 0.0:
conditions.append(
f"Contradiction score exceeds 0.60 (currently {summary.contradiction_score:.2f})"
)
if summary.confidence > 0.0:
conditions.append(
f"Confidence drops below {summary.confidence * 0.7:.2f}"
)
if summary.material_risks:
conditions.append(
f"Material risk materialises: {summary.material_risks[0]}"
)
return conditions
# ---------------------------------------------------------------------------
# Expected value computation (Requirements: 14.114.6)
# ---------------------------------------------------------------------------
# Horizon days mapping for EV computation
_EV_HORIZON_DAYS: dict[str, float] = {
"intraday": 1.0,
"1d": 1.0,
"7d": 7.0,
"30d": 30.0,
"90d": 90.0,
}
def compute_expected_value(
p_bull: float,
strength: float,
sigma_20: float,
horizon_days: float,
) -> float:
"""Compute expected value for the recommendation gate.
Formula:
R_up = strength · σ_20 · √(horizon_days)
R_down = (1 - strength) · σ_20 · √(horizon_days)
EV = P_bull · R_up - P_bear · R_down
where P_bear = 1 - P_bull.
Args:
p_bull: Bayesian bullish probability in [0, 1].
strength: Trend strength in [0, 1].
sigma_20: 20-day return standard deviation.
horizon_days: Number of days for the projection horizon.
Returns:
Expected value (can be negative).
Requirements: 14.1, 14.2
"""
p_bear = 1.0 - p_bull
sqrt_horizon = math.sqrt(max(horizon_days, 0.0))
r_up = strength * sigma_20 * sqrt_horizon
r_down = (1.0 - strength) * sigma_20 * sqrt_horizon
ev = p_bull * r_up - p_bear * r_down
# Guard against NaN/infinity from extreme inputs
if math.isnan(ev) or math.isinf(ev):
return 0.0
return ev
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def evaluate_eligibility(
summary: TrendSummary,
config: EligibilityConfig = DEFAULT_ELIGIBILITY_CONFIG,
*,
probabilistic: bool = False,
p_bull: float | None = None,
sigma_20: float = 0.01,
) -> EligibilityResult:
"""Evaluate a trend summary for recommendation eligibility.
This is the single deterministic entry point. It:
1. Applies gate checks (confidence, strength, contradiction, evidence)
2. Maps trend direction + strength to an action type
3. Determines the highest allowed execution mode
4. Computes position sizing from portfolio rules
5. Derives invalidation conditions
6. (probabilistic) Applies EV gate: EV > threshold to proceed
When ``probabilistic=True``:
- Computes EV = P_bull · R_up - P_bear · R_down
- When EV > threshold (default 0.005), allows recommendation through
- When EV ≤ threshold, forces recommendation to informational mode
- Populates expected_value, p_bull, pipeline_mode on result
When ``probabilistic=False``:
- Skips EV gate entirely (existing behavior)
Args:
summary: The current trend summary.
config: Eligibility configuration thresholds.
probabilistic: Use EV gate when True.
p_bull: Bayesian bullish probability (required when probabilistic=True).
sigma_20: 20-day return standard deviation for EV computation.
Returns an EligibilityResult with the full decision trace.
Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6
"""
rejection_reasons = _check_gates(summary, config)
# Even if rejected, we still compute action/mode for the trace
action = _determine_action(summary, config)
mode = _determine_mode(summary, action, config)
sizing = _compute_position_sizing(summary, config)
horizon = _map_time_horizon(summary.window.value)
invalidation = _derive_invalidation_conditions(summary, action)
eligible = len(rejection_reasons) == 0
# If not eligible, force mode to informational (Requirement 7.4)
if not eligible:
mode = RecommendationMode.INFORMATIONAL
# EV gate (Requirement 14.114.6)
ev_value: float | None = None
if probabilistic and p_bull is not None:
horizon_days = _EV_HORIZON_DAYS.get(summary.window.value, 7.0)
ev_value = compute_expected_value(
p_bull=p_bull,
strength=summary.trend_strength,
sigma_20=sigma_20,
horizon_days=horizon_days,
)
if ev_value <= config.ev_threshold:
# Force to informational mode (Req 14.4)
mode = RecommendationMode.INFORMATIONAL
return EligibilityResult(
eligible=eligible,
action=action,
mode=mode,
position_sizing=sizing,
rejection_reasons=rejection_reasons,
time_horizon=horizon,
invalidation_conditions=invalidation,
ev_value=ev_value,
p_bull=p_bull if probabilistic else None,
pipeline_mode="probabilistic" if probabilistic else "heuristic",
)
# ---------------------------------------------------------------------------
# V3 Return Distribution and EV Gate (Requirements 12.112.7)
# ---------------------------------------------------------------------------
_V3_MIN_EDGE: dict[str, float] = {
"panic": 0.0100,
"trend_following": 0.0035,
"mean_reversion": 0.0050,
"uncertainty": 0.0075,
}
_V3_ELIGIBILITY_THRESHOLDS: dict[str, tuple[float, float, float]] = {
# (confidence_min, contradiction_max, strength_min)
"panic": (0.70, 0.25, 0.36),
"trend_following": (0.55, 0.40, 0.20),
"mean_reversion": (0.60, 0.35, 0.26),
"uncertainty": (0.65, 0.30, 0.30),
}
@dataclass(frozen=True)
class ReturnDistribution:
"""V3 return distribution model output.
Encapsulates the horizon-scaled volatility, expected return, risk-adjusted
expected value, regime minimum edge, and final eligibility decision.
Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
"""
sigma_h: float # realized_vol_20d * sqrt(horizon_days / 252)
mu_h: float # tanh(A_projected / 3.0) * confidence * sigma_h
ev_long: float # mu_h - costs - 0.10 * CVaR_5
min_edge: float # regime-specific minimum edge
eligible: bool
def compute_return_distribution(
a_projected: float,
confidence: float,
realized_vol_20d: float,
horizon_days: int,
costs: float,
regime: str,
*,
confidence_actual: float = 1.0,
contradiction: float = 0.0,
n_eff_total: float = 0.0,
data_quality: float = 1.0,
) -> ReturnDistribution:
"""Compute the v3 return distribution and EV gate eligibility.
Implements the return distribution model from the v3 math spec:
- sigma_h: horizon-scaled volatility
- mu_h: expected return using tanh-compressed projected alpha
- CVaR_5: Gaussian approximation of 5th percentile tail loss
- EV_long: risk-adjusted expected value after costs and tail risk
Eligibility requires:
- EV_long > regime min_edge
- EV_long > max(0.0025, 0.25 * costs)
- confidence_actual >= regime confidence_min
- contradiction <= regime contradiction_max
- n_eff_total >= 2.0
- data_quality >= 0.50
Args:
a_projected: Projected evidence state A_projected_h from projection.
confidence: Multiplicative confidence from the v3 pipeline.
realized_vol_20d: 20-day realized annualized volatility.
If <= 0 or unavailable, defaults to 0.25.
horizon_days: Trading days for the horizon (1, 7, 30, or 90).
costs: Total costs (spread + slippage + commission).
regime: Market regime string (panic, trend_following, mean_reversion, uncertainty).
confidence_actual: The raw confidence value for threshold checks (defaults to
same as confidence if not separately provided).
contradiction: Contradiction score in [0, 1].
n_eff_total: Total effective evidence count across clusters.
data_quality: Data quality score in [0, 1].
Returns:
ReturnDistribution with computed fields and eligibility decision.
Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
"""
# Default volatility when unavailable (Req 12.7)
if realized_vol_20d is None or realized_vol_20d <= 0: # type: ignore[redundant-expr]
realized_vol_20d = 0.25
# Req 12.1: sigma_h = realized_vol_20d * sqrt(horizon_days / 252)
sigma_h = realized_vol_20d * math.sqrt(horizon_days / 252.0)
# Req 12.2: mu_h = tanh(A_projected / 3.0) * confidence * sigma_h
mu_h = math.tanh(a_projected / 3.0) * confidence * sigma_h
# Req 12.3: CVaR_5 = sigma_h * 1.645 * 1.4
cvar_5 = sigma_h * 1.645 * 1.4
# Req 12.3: EV_long = mu_h - costs - 0.10 * CVaR_5
ev_long = mu_h - costs - 0.10 * cvar_5
# Req 12.4: regime-specific min_edge
min_edge = _V3_MIN_EDGE.get(regime, _V3_MIN_EDGE["uncertainty"])
# Req 12.5: EV_long > min_edge AND EV_long > max(0.0025, 0.25 * costs)
ev_gate_passed = ev_long > min_edge and ev_long > max(0.0025, 0.25 * costs)
# Req 12.6: Additional eligibility checks
thresholds = _V3_ELIGIBILITY_THRESHOLDS.get(
regime, _V3_ELIGIBILITY_THRESHOLDS["uncertainty"]
)
confidence_min, contradiction_max, _strength_min = thresholds
quality_gate_passed = (
confidence_actual >= confidence_min
and contradiction <= contradiction_max
and n_eff_total >= 2.0
and data_quality >= 0.50
)
eligible = ev_gate_passed and quality_gate_passed
return ReturnDistribution(
sigma_h=sigma_h,
mu_h=mu_h,
ev_long=ev_long,
min_edge=min_edge,
eligible=eligible,
)
# ===========================================================================
# v3 Regime-Aware Eligibility and Mode Escalation (Requirements 13.113.5)
# ===========================================================================
# Direction thresholds for action mapping (from bayesian.py)
_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = {
"panic": (0.68, 0.32),
"trend_following": (0.60, 0.40),
"mean_reversion": (0.63, 0.37),
"uncertainty": (0.65, 0.35),
}
@dataclass(frozen=True)
class V3Eligibility:
"""Result of v3 regime-aware eligibility evaluation.
Requirements: 13.113.5
"""
action: str # "BUY", "SELL", "HOLD", "WATCH"
mode: str # "live", "paper", "informational"
eligible: bool # meets regime thresholds
reasons: list[str] # reasons for non-eligibility or downgrade
def compute_v3_eligibility(
p_up: float,
ev_long: float,
min_edge: float,
confidence: float,
contradiction: float,
strength: float,
n_eff_total: float,
data_quality: float,
regime: str,
has_existing_position: bool = False,
risk_engine_passed: bool = True,
) -> V3Eligibility:
"""Compute regime-aware eligibility and mode escalation.
Requirements: 13.113.5
Steps:
1. Check regime-specific eligibility gates (confidence, contradiction, strength)
2. Determine action (BUY/SELL/HOLD/WATCH) based on P_up and EV
3. Escalate mode (live/paper/informational) based on signal quality
Args:
p_up: Posterior probability of upward move from Bayesian posterior.
ev_long: Expected value from return distribution.
min_edge: Regime-specific minimum edge from EV gate.
confidence: Multiplicative confidence score in [0, 1].
contradiction: LLR entropy contradiction in [0, 1].
strength: Signal strength = abs(2 * P_up - 1).
n_eff_total: Effective evidence count across all clusters.
data_quality: Data quality score in [0, 1].
regime: Current market regime string.
has_existing_position: Whether the entity already has an open position.
risk_engine_passed: Whether the risk engine approved the trade.
Returns:
V3Eligibility with action, mode, eligible flag, and reasons.
"""
reasons: list[str] = []
# --- 1. Regime-specific eligibility gates (Requirement 13.1) ---
conf_min, contra_max, str_min = _V3_ELIGIBILITY_THRESHOLDS.get(
regime, (0.65, 0.30, 0.30) # default to uncertainty thresholds
)
eligible = True
if confidence < conf_min:
eligible = False
reasons.append(f"confidence {confidence:.3f} < regime min {conf_min:.2f}")
if contradiction > contra_max:
eligible = False
reasons.append(
f"contradiction {contradiction:.3f} > regime max {contra_max:.2f}"
)
if strength < str_min:
eligible = False
reasons.append(f"strength {strength:.3f} < regime min {str_min:.2f}")
if data_quality < 0.50:
eligible = False
reasons.append(f"data_quality {data_quality:.3f} < 0.50")
if n_eff_total < 2.0:
eligible = False
reasons.append(f"n_eff_total {n_eff_total:.2f} < 2.0")
# --- 2. Action mapping (Requirement 13.2) ---
bull_thresh, _bear_thresh = _V3_DIRECTION_THRESHOLDS.get(
regime, (0.65, 0.35)
)
if not eligible:
action = "WATCH"
elif p_up >= bull_thresh and ev_long > min_edge:
action = "BUY"
elif has_existing_position and ev_long <= 0:
# SELL when existing position and exit EV > hold EV
# Simplified: EV_exit > EV_hold approximated as ev_long <= 0
# (holding has negative expected value → better to exit)
action = "SELL"
elif has_existing_position:
action = "HOLD"
else:
action = "WATCH"
# --- 3. Mode escalation (Requirements 13.3, 13.4, 13.5) ---
if action in ("BUY", "SELL"):
# Check live eligibility (Requirement 13.3)
if (
confidence >= 0.75
and contradiction <= 0.20
and n_eff_total >= 5
and ev_long > 2 * min_edge
and risk_engine_passed
):
mode = "live"
# Check paper eligibility (Requirement 13.4)
elif (
confidence >= 0.60
and ev_long > min_edge
and risk_engine_passed
):
mode = "paper"
else:
mode = "informational"
if confidence < 0.60:
reasons.append(
f"paper requires confidence >= 0.60, got {confidence:.3f}"
)
if ev_long <= min_edge:
reasons.append(
f"paper requires EV > min_edge ({min_edge:.4f}), got {ev_long:.4f}"
)
if not risk_engine_passed:
reasons.append("risk engine did not pass")
else:
# HOLD and WATCH are always informational (Requirement 13.5)
mode = "informational"
return V3Eligibility(
action=action,
mode=mode,
eligible=eligible,
reasons=reasons,
)