"""Canary signal influence — paper trading with v3 signals. Enables v3 signals in paper trading at a small percentage, tracks extraction correctness separately from trading outcomes, reviews material recommendation divergences, and requires explicit owner approval for full promotion. """ from __future__ import annotations import enum from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 class PromotionStatus(str, enum.Enum): """Status of the canary promotion process.""" PENDING = "pending" PAPER_TRADING = "paper_trading" AWAITING_REVIEW = "awaiting_review" APPROVED = "approved" REJECTED = "rejected" @dataclass class DivergenceRecord: """Record of a material recommendation divergence between v2 and v3.""" record_id: UUID document_id: str timestamp: datetime v2_recommendation: dict[str, Any] v3_recommendation: dict[str, Any] divergence_type: str # e.g., "direction_opposite", "magnitude_significant" impact_estimate: float = 0.0 # Estimated impact on portfolio reviewed: bool = False reviewer_notes: str = "" @classmethod def create( cls, document_id: str, v2_recommendation: dict[str, Any], v3_recommendation: dict[str, Any], divergence_type: str, impact_estimate: float = 0.0, ) -> DivergenceRecord: return cls( record_id=uuid4(), document_id=document_id, timestamp=datetime.now(timezone.utc), v2_recommendation=v2_recommendation, v3_recommendation=v3_recommendation, divergence_type=divergence_type, impact_estimate=impact_estimate, ) @dataclass class SignalInfluenceConfig: """Configuration for canary signal influence in paper trading.""" enabled: bool = False percentage: int = 5 # Start at 5% of paper trading signals require_owner_approval: bool = True owner_id: str = "" # Reporting thresholds material_divergence_threshold: float = 0.20 max_divergence_rate: float = 0.15 # Separation of concerns report_extraction_separately: bool = True report_trading_separately: bool = True @dataclass class SignalInfluenceTracker: """Tracks canary signal influence in paper trading. Reports extraction correctness separately from trading outcomes. Reviews material divergences and tracks promotion readiness. """ config: SignalInfluenceConfig promotion_status: PromotionStatus = PromotionStatus.PENDING _divergences: list[DivergenceRecord] = field(default_factory=list) _extraction_metrics: dict[str, float] = field(default_factory=dict) _trading_metrics: dict[str, float] = field(default_factory=dict) _total_signals: int = 0 _v3_signals: int = 0 _approval_timestamp: datetime | None = None _approver_id: str = "" def start_paper_trading(self) -> None: """Begin paper trading with v3 signals.""" self.config.enabled = True self.promotion_status = PromotionStatus.PAPER_TRADING def record_signal(self, is_v3: bool = False) -> None: """Record a signal processed.""" self._total_signals += 1 if is_v3: self._v3_signals += 1 def record_divergence(self, divergence: DivergenceRecord) -> None: """Record a material recommendation divergence.""" self._divergences.append(divergence) def update_extraction_metrics(self, metrics: dict[str, float]) -> None: """Update extraction correctness metrics (separate from trading).""" self._extraction_metrics.update(metrics) def update_trading_metrics(self, metrics: dict[str, float]) -> None: """Update trading outcome metrics (separate from extraction).""" self._trading_metrics.update(metrics) @property def divergence_rate(self) -> float: if self._v3_signals == 0: return 0.0 return len(self._divergences) / self._v3_signals @property def unreviewed_divergences(self) -> list[DivergenceRecord]: return [d for d in self._divergences if not d.reviewed] def request_approval(self) -> None: """Move to awaiting review status.""" self.promotion_status = PromotionStatus.AWAITING_REVIEW def approve(self, approver_id: str) -> bool: """Approve promotion. Requires owner approval if configured. Returns False if approval requirements are not met. """ if self.config.require_owner_approval: if not approver_id: return False if self.config.owner_id and approver_id != self.config.owner_id: return False # Check all gates if not self._all_gates_pass(): return False self.promotion_status = PromotionStatus.APPROVED self._approval_timestamp = datetime.now(timezone.utc) self._approver_id = approver_id return True def reject(self, reason: str = "") -> None: """Reject promotion.""" self.promotion_status = PromotionStatus.REJECTED def _all_gates_pass(self) -> bool: """Check if extraction correctness gates pass. Trading outcomes explicitly do NOT override correctness gates (Requirement 16.10). """ # Divergence rate must be below threshold if self.divergence_rate > self.config.max_divergence_rate: return False # All divergences must be reviewed if self.unreviewed_divergences: return False return True def summary(self) -> dict[str, Any]: return { "enabled": self.config.enabled, "status": self.promotion_status.value, "percentage": self.config.percentage, "total_signals": self._total_signals, "v3_signals": self._v3_signals, "divergence_count": len(self._divergences), "divergence_rate": self.divergence_rate, "unreviewed_divergences": len(self.unreviewed_divergences), "extraction_metrics": self._extraction_metrics, "trading_metrics": self._trading_metrics, }