phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
"""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
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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.02
|
||||
# Maximum portfolio allocation percentage
|
||||
max_portfolio_pct: float = 0.05
|
||||
# Base max loss percentage
|
||||
base_max_loss_pct: float = 0.005
|
||||
# Maximum max loss percentage
|
||||
max_max_loss_pct: float = 0.01
|
||||
# Confidence scaling: higher confidence → larger position (linear)
|
||||
confidence_sizing_weight: float = 0.5
|
||||
# Contradiction penalty: higher contradiction → smaller position
|
||||
contradiction_sizing_penalty: float = 0.3
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 → larger allocation (up to max).
|
||||
Higher contradiction → smaller allocation (penalty).
|
||||
"""
|
||||
# Start from base allocation
|
||||
confidence_scale = config.base_portfolio_pct + (
|
||||
config.confidence_sizing_weight
|
||||
* summary.confidence
|
||||
* (config.max_portfolio_pct - config.base_portfolio_pct)
|
||||
)
|
||||
|
||||
# Apply contradiction penalty
|
||||
contradiction_penalty = config.contradiction_sizing_penalty * summary.contradiction_score
|
||||
portfolio_pct = confidence_scale * (1.0 - contradiction_penalty)
|
||||
|
||||
# Clamp to bounds
|
||||
portfolio_pct = max(config.base_portfolio_pct * 0.5, min(portfolio_pct, config.max_portfolio_pct))
|
||||
|
||||
# Max loss scales similarly
|
||||
loss_scale = config.base_max_loss_pct + (
|
||||
config.confidence_sizing_weight
|
||||
* summary.confidence
|
||||
* (config.max_max_loss_pct - config.base_max_loss_pct)
|
||||
)
|
||||
max_loss_pct = loss_scale * (1.0 - contradiction_penalty)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def evaluate_eligibility(
|
||||
summary: TrendSummary,
|
||||
config: EligibilityConfig = DEFAULT_ELIGIBILITY_CONFIG,
|
||||
) -> 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
|
||||
|
||||
Returns an EligibilityResult with the full decision trace.
|
||||
"""
|
||||
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
|
||||
|
||||
return EligibilityResult(
|
||||
eligible=eligible,
|
||||
action=action,
|
||||
mode=mode,
|
||||
position_sizing=sizing,
|
||||
rejection_reasons=rejection_reasons,
|
||||
time_horizon=horizon,
|
||||
invalidation_conditions=invalidation,
|
||||
)
|
||||
Reference in New Issue
Block a user