inmobiliaria-analytics/docs/01-fase-alcance-inicial/IAI-008-ml-analytics/especificaciones/ET-ML-002-opportunities.md
rckrdmrd f570727617 feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:40 -06:00

37 KiB

id title type epic status version project created_date updated_date
ET-ML-opportunities Especificacion Tecnica - Deteccion de Oportunidades de Inversion Technical Specification IAI-008 Draft 1.0 inmobiliaria-analytics 2026-01-04 2026-01-04

ET-IA-008-opportunities: Deteccion de Oportunidades de Inversion


1. Resumen

Sistema de deteccion automatica de oportunidades de inversion inmobiliaria que identifica propiedades subvaluadas, zonas emergentes con potencial de apreciacion, y alertas en tiempo real para inversores.


2. Tipos de Oportunidades

opportunity_types:
  undervalued_property:
    description: "Propiedad con precio < valor estimado AVM"
    min_discount: 10%
    confidence_threshold: 0.75

  emerging_zone:
    description: "Zona con tendencia alcista sostenida"
    min_appreciation: 15%  # anual
    min_data_points: 12

  distressed_sale:
    description: "Venta urgente/remate"
    indicators:
      - price_drop_pct > 20%
      - days_on_market > 120
      - keywords: [urgente, oportunidad, remate]

  arbitrage:
    description: "Diferencia de precio entre portales"
    min_difference: 8%

  rental_yield:
    description: "Alto rendimiento de renta"
    min_yield: 7%  # anual
    cap_rate_threshold: 8%

3. Arquitectura del Sistema

┌─────────────────────────────────────────────────────────────────────┐
│                   OPPORTUNITY DETECTION ENGINE                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌───────────────┐   ┌───────────────┐   ┌───────────────┐         │
│  │   Property    │   │    Zone       │   │   Market      │         │
│  │   Analyzer    │   │   Analyzer    │   │   Signals     │         │
│  └───────┬───────┘   └───────┬───────┘   └───────┬───────┘         │
│          │                   │                   │                  │
│          └───────────┬───────┴───────────────────┘                  │
│                      │                                               │
│              ┌───────▼───────┐                                      │
│              │  Opportunity  │                                      │
│              │   Scorer      │                                      │
│              └───────┬───────┘                                      │
│                      │                                               │
│    ┌─────────────────┼─────────────────┐                            │
│    │                 │                 │                            │
│    ▼                 ▼                 ▼                            │
│ ┌──────┐        ┌──────┐        ┌──────────┐                       │
│ │Alert │        │Report│        │Dashboard │                       │
│ │Engine│        │ Gen  │        │  Feed    │                       │
│ └──────┘        └──────┘        └──────────┘                       │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

4. Detector de Propiedades Subvaluadas

# src/ml/opportunities/undervalued_detector.py
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
import numpy as np

from ..avm.ensemble import AVMEnsemble
from ..avm.explainability import AVMExplainer

@dataclass
class UndervaluedOpportunity:
    property_id: str
    source_url: str
    title: str

    listed_price: float
    estimated_value: float
    discount_pct: float

    confidence: float
    explanation: dict

    property_details: dict
    comparables: List[dict]

    detected_at: datetime
    urgency_score: float  # 0-100
    recommendation: str

class UndervaluedDetector:
    """Detectar propiedades subvaluadas vs AVM"""

    def __init__(
        self,
        avm_model: AVMEnsemble,
        explainer: AVMExplainer,
        min_discount_pct: float = 10.0,
        min_confidence: float = 0.75
    ):
        self.avm = avm_model
        self.explainer = explainer
        self.min_discount = min_discount_pct
        self.min_confidence = min_confidence

    def detect(
        self,
        property_data: dict,
        listed_price: float
    ) -> Optional[UndervaluedOpportunity]:
        """Analizar si una propiedad esta subvaluada"""

        # 1. Obtener valuacion AVM
        features = self._prepare_features(property_data)
        estimated_value, lower, upper = self.avm.predict_with_uncertainty(features)
        estimated_value = float(estimated_value[0])

        # 2. Calcular descuento
        discount_pct = ((estimated_value - listed_price) / estimated_value) * 100

        if discount_pct < self.min_discount:
            return None  # No es una oportunidad

        # 3. Calcular confianza
        confidence = self._calculate_confidence(
            features, estimated_value, lower[0], upper[0],
            property_data
        )

        if confidence < self.min_confidence:
            return None  # Confianza insuficiente

        # 4. Generar explicacion
        explanation = self.explainer.explain(features)

        # 5. Buscar comparables
        from .comparables import ComparablesFinder
        comparables = ComparablesFinder().find(property_data, limit=5)

        # 6. Calcular urgencia
        urgency = self._calculate_urgency(property_data, discount_pct)

        # 7. Generar recomendacion
        recommendation = self._generate_recommendation(
            discount_pct, confidence, urgency, property_data
        )

        return UndervaluedOpportunity(
            property_id=property_data.get('id', 'unknown'),
            source_url=property_data.get('source_url', ''),
            title=property_data.get('title', ''),
            listed_price=listed_price,
            estimated_value=estimated_value,
            discount_pct=round(discount_pct, 1),
            confidence=round(confidence, 2),
            explanation=explanation,
            property_details=property_data,
            comparables=comparables,
            detected_at=datetime.utcnow(),
            urgency_score=urgency,
            recommendation=recommendation
        )

    def batch_detect(
        self,
        properties: List[dict]
    ) -> List[UndervaluedOpportunity]:
        """Detectar oportunidades en batch"""

        opportunities = []

        for prop in properties:
            listed_price = prop.get('price')
            if not listed_price:
                continue

            opportunity = self.detect(prop, listed_price)
            if opportunity:
                opportunities.append(opportunity)

        # Ordenar por score combinado (descuento * confianza * urgencia)
        opportunities.sort(
            key=lambda x: x.discount_pct * x.confidence * (x.urgency_score / 100),
            reverse=True
        )

        return opportunities

    def _calculate_confidence(
        self,
        features: np.ndarray,
        prediction: float,
        lower: float,
        upper: float,
        property_data: dict
    ) -> float:
        """Calcular confianza de la deteccion"""

        scores = []

        # 1. Ancho del intervalo de confianza
        interval_pct = (upper - lower) / prediction
        interval_score = max(0, 1 - interval_pct * 2)
        scores.append(interval_score * 0.3)

        # 2. Calidad de datos
        required_fields = ['constructed_area_m2', 'bedrooms', 'bathrooms', 'latitude', 'longitude']
        data_completeness = sum(1 for f in required_fields if property_data.get(f)) / len(required_fields)
        scores.append(data_completeness * 0.25)

        # 3. Disponibilidad de comparables (proxy)
        neighborhood = property_data.get('neighborhood', '')
        # Asumir mas confianza en zonas conocidas
        known_zones = ['providencia', 'americana', 'lafayette', 'chapalita']
        zone_score = 1.0 if neighborhood.lower() in known_zones else 0.7
        scores.append(zone_score * 0.25)

        # 4. Precio en rango razonable
        if prediction > 500000 and prediction < 50000000:  # 500K - 50M MXN
            scores.append(0.2)
        else:
            scores.append(0.1)

        return sum(scores)

    def _calculate_urgency(
        self,
        property_data: dict,
        discount_pct: float
    ) -> float:
        """Calcular urgencia de actuar (0-100)"""

        urgency = 50  # Base

        # Mayor descuento = mayor urgencia
        urgency += min(30, discount_pct * 1.5)

        # Propiedad recien publicada = alta urgencia
        days_listed = property_data.get('days_on_market', 30)
        if days_listed < 7:
            urgency += 20
        elif days_listed < 14:
            urgency += 10

        # Keywords de urgencia en descripcion
        description = property_data.get('description', '').lower()
        urgent_keywords = ['urgente', 'urge', 'oportunidad', 'negociable', 'precio a tratar']
        if any(kw in description for kw in urgent_keywords):
            urgency += 15

        # Zona premium con descuento = muy urgente
        if property_data.get('is_premium_zone') and discount_pct > 15:
            urgency += 15

        return min(100, urgency)

    def _generate_recommendation(
        self,
        discount_pct: float,
        confidence: float,
        urgency: float,
        property_data: dict
    ) -> str:
        """Generar recomendacion de texto"""

        if discount_pct > 20 and confidence > 0.85 and urgency > 70:
            return "EXCELENTE OPORTUNIDAD - Actuar inmediatamente. Descuento significativo con alta confianza."

        if discount_pct > 15 and confidence > 0.80:
            return "BUENA OPORTUNIDAD - Realizar due diligence rapido. Potencial de apreciacion."

        if discount_pct > 10 and confidence > 0.75:
            return "OPORTUNIDAD MODERADA - Evaluar con mas detalle. Precio atractivo pero verificar condiciones."

        return "OPORTUNIDAD A EVALUAR - Investigar mas antes de decidir."

    def _prepare_features(self, property_data: dict) -> np.ndarray:
        """Preparar features para el modelo"""
        # Implementar transformacion
        pass

5. Detector de Zonas Emergentes

# src/ml/opportunities/emerging_zones.py
from dataclasses import dataclass
from typing import List, Dict, Optional
from datetime import datetime, timedelta
import pandas as pd
import numpy as np
from scipy import stats

@dataclass
class EmergingZone:
    zone_name: str
    municipality: str

    appreciation_12m: float  # % anual
    appreciation_trend: str  # up, stable, accelerating
    volatility: float

    avg_price_m2: float
    price_vs_city_avg: float  # ratio

    investment_score: float  # 0-100
    risk_level: str  # low, medium, high

    drivers: List[str]  # Razones del crecimiento
    forecast_12m: float  # % estimado proximo ano

    sample_properties: List[dict]

class EmergingZoneDetector:
    """Detectar zonas con potencial de apreciacion"""

    def __init__(self, db_connection, min_appreciation: float = 10.0):
        self.db = db_connection
        self.min_appreciation = min_appreciation

    def detect_emerging_zones(
        self,
        lookback_months: int = 24,
        min_samples: int = 50
    ) -> List[EmergingZone]:
        """Identificar zonas emergentes"""

        # 1. Obtener datos historicos por zona
        zone_data = self._get_zone_price_history(lookback_months, min_samples)

        emerging = []

        for zone_name, df in zone_data.items():
            analysis = self._analyze_zone(zone_name, df)

            if analysis and analysis['appreciation_12m'] >= self.min_appreciation:
                zone = EmergingZone(
                    zone_name=zone_name,
                    municipality=analysis['municipality'],
                    appreciation_12m=analysis['appreciation_12m'],
                    appreciation_trend=analysis['trend'],
                    volatility=analysis['volatility'],
                    avg_price_m2=analysis['current_price_m2'],
                    price_vs_city_avg=analysis['vs_city_avg'],
                    investment_score=analysis['score'],
                    risk_level=analysis['risk'],
                    drivers=analysis['drivers'],
                    forecast_12m=analysis['forecast'],
                    sample_properties=analysis['samples']
                )
                emerging.append(zone)

        # Ordenar por score
        emerging.sort(key=lambda x: x.investment_score, reverse=True)

        return emerging

    def _get_zone_price_history(
        self,
        months: int,
        min_samples: int
    ) -> Dict[str, pd.DataFrame]:
        """Obtener historial de precios por zona"""

        query = """
            SELECT
                neighborhood,
                municipality,
                DATE_TRUNC('month', first_seen_at) as month,
                AVG(price / constructed_area_m2) as avg_price_m2,
                PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price / constructed_area_m2) as median_price_m2,
                COUNT(*) as sample_count,
                STDDEV(price / constructed_area_m2) as std_price_m2
            FROM properties
            WHERE first_seen_at > NOW() - INTERVAL '%s months'
              AND property_type IN ('casa', 'departamento')
              AND status != 'removed'
              AND constructed_area_m2 > 0
            GROUP BY neighborhood, municipality, DATE_TRUNC('month', first_seen_at)
            HAVING COUNT(*) >= 10
            ORDER BY neighborhood, month
        """

        result = pd.read_sql(query % months, self.db)

        # Agrupar por zona
        zones = {}
        for zone, group in result.groupby('neighborhood'):
            if len(group) >= 12 and group['sample_count'].sum() >= min_samples:
                zones[zone] = group

        return zones

    def _analyze_zone(
        self,
        zone_name: str,
        df: pd.DataFrame
    ) -> Optional[Dict]:
        """Analizar tendencias de una zona"""

        if len(df) < 12:
            return None

        df = df.sort_values('month')

        # Calcular apreciacion
        current_price = df['median_price_m2'].iloc[-3:].mean()
        year_ago_price = df['median_price_m2'].iloc[:3].mean()

        appreciation = ((current_price - year_ago_price) / year_ago_price) * 100

        # Calcular tendencia (regresion lineal)
        x = np.arange(len(df))
        y = df['median_price_m2'].values
        slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)

        # Determinar tipo de tendencia
        recent_slope = self._calculate_recent_slope(df)
        if recent_slope > slope * 1.2:
            trend = "accelerating"
        elif recent_slope > slope * 0.8:
            trend = "up"
        else:
            trend = "stable"

        # Volatilidad
        volatility = df['std_price_m2'].mean() / df['median_price_m2'].mean()

        # Precio vs promedio ciudad
        city_avg = self._get_city_avg_price_m2()
        vs_city = current_price / city_avg if city_avg > 0 else 1.0

        # Score de inversion
        score = self._calculate_investment_score(
            appreciation, trend, volatility, vs_city, r_value
        )

        # Nivel de riesgo
        risk = self._assess_risk(volatility, len(df), vs_city)

        # Drivers de crecimiento
        drivers = self._identify_drivers(zone_name, df, appreciation)

        # Forecast simple
        forecast = self._forecast_appreciation(df, trend)

        # Propiedades ejemplo
        samples = self._get_sample_properties(zone_name)

        return {
            'municipality': df['municipality'].iloc[0],
            'appreciation_12m': round(appreciation, 1),
            'trend': trend,
            'volatility': round(volatility, 3),
            'current_price_m2': round(current_price, 0),
            'vs_city_avg': round(vs_city, 2),
            'score': round(score, 1),
            'risk': risk,
            'drivers': drivers,
            'forecast': round(forecast, 1),
            'samples': samples,
        }

    def _calculate_recent_slope(self, df: pd.DataFrame) -> float:
        """Calcular pendiente de ultimos 6 meses"""
        recent = df.tail(6)
        x = np.arange(len(recent))
        y = recent['median_price_m2'].values
        slope, *_ = stats.linregress(x, y)
        return slope

    def _calculate_investment_score(
        self,
        appreciation: float,
        trend: str,
        volatility: float,
        vs_city: float,
        r_squared: float
    ) -> float:
        """Calcular score de inversion 0-100"""

        score = 0

        # Apreciacion (max 40 puntos)
        score += min(40, appreciation * 2)

        # Tendencia (max 20 puntos)
        trend_scores = {'accelerating': 20, 'up': 15, 'stable': 10}
        score += trend_scores.get(trend, 5)

        # Baja volatilidad (max 15 puntos)
        score += max(0, 15 - volatility * 100)

        # Precio bajo vs ciudad (oportunidad de catch-up, max 15 puntos)
        if vs_city < 0.8:
            score += 15
        elif vs_city < 1.0:
            score += 10

        # Confianza estadistica (max 10 puntos)
        score += r_squared * 10

        return min(100, max(0, score))

    def _assess_risk(
        self,
        volatility: float,
        data_points: int,
        vs_city: float
    ) -> str:
        """Evaluar nivel de riesgo"""

        risk_score = 0

        # Volatilidad
        if volatility > 0.2:
            risk_score += 3
        elif volatility > 0.1:
            risk_score += 2

        # Datos insuficientes
        if data_points < 18:
            risk_score += 2
        elif data_points < 24:
            risk_score += 1

        # Zona muy barata (puede indicar problemas)
        if vs_city < 0.5:
            risk_score += 2

        if risk_score >= 5:
            return "high"
        elif risk_score >= 3:
            return "medium"
        return "low"

    def _identify_drivers(
        self,
        zone_name: str,
        df: pd.DataFrame,
        appreciation: float
    ) -> List[str]:
        """Identificar posibles drivers del crecimiento"""

        drivers = []

        # Incremento de inventario = desarrollo activo
        inventory_growth = self._check_inventory_growth(zone_name)
        if inventory_growth > 20:
            drivers.append("Desarrollo inmobiliario activo")

        # Mejora en calidad (mas amenidades)
        if self._check_quality_improvement(zone_name):
            drivers.append("Mejora en calidad de propiedades")

        # Proximidad a zona premium
        if self._near_premium_zone(zone_name):
            drivers.append("Efecto derrame de zona premium")

        # Alta demanda
        if self._check_high_demand(zone_name):
            drivers.append("Alta demanda / bajo inventario")

        # Nuevo desarrollo comercial/transporte
        if self._check_infrastructure(zone_name):
            drivers.append("Nuevo desarrollo de infraestructura")

        if not drivers:
            if appreciation > 15:
                drivers.append("Tendencia general de mercado")
            else:
                drivers.append("Crecimiento organico")

        return drivers

    def _forecast_appreciation(
        self,
        df: pd.DataFrame,
        trend: str
    ) -> float:
        """Pronosticar apreciacion para proximo ano"""

        recent = df.tail(6)
        x = np.arange(len(recent))
        y = recent['median_price_m2'].values

        slope, intercept, *_ = stats.linregress(x, y)

        # Proyectar 12 meses
        current = y[-1]
        projected = intercept + slope * (len(df) + 12)

        forecast = ((projected - current) / current) * 100

        # Ajustar por tipo de tendencia
        if trend == "accelerating":
            forecast *= 1.2
        elif trend == "stable":
            forecast *= 0.8

        # Limitar a rangos razonables
        return max(-20, min(50, forecast))

    def _get_sample_properties(
        self,
        zone_name: str,
        limit: int = 3
    ) -> List[dict]:
        """Obtener propiedades ejemplo de la zona"""

        query = """
            SELECT id, title, price, constructed_area_m2, bedrooms,
                   source_url, images
            FROM properties
            WHERE neighborhood = %s
              AND status = 'active'
            ORDER BY last_seen_at DESC
            LIMIT %s
        """
        # Implementar query real
        return []

    # Metodos auxiliares (implementar segun base de datos)
    def _get_city_avg_price_m2(self) -> float:
        return 30000  # Placeholder

    def _check_inventory_growth(self, zone: str) -> float:
        return 10  # Placeholder

    def _check_quality_improvement(self, zone: str) -> bool:
        return False

    def _near_premium_zone(self, zone: str) -> bool:
        return False

    def _check_high_demand(self, zone: str) -> bool:
        return False

    def _check_infrastructure(self, zone: str) -> bool:
        return False

6. Sistema de Alertas

# src/ml/opportunities/alerts.py
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
from enum import Enum
import asyncio

class AlertChannel(Enum):
    EMAIL = "email"
    PUSH = "push"
    SMS = "sms"
    WEBHOOK = "webhook"

class AlertPriority(Enum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3
    URGENT = 4

@dataclass
class AlertConfig:
    user_id: str
    name: str
    enabled: bool

    # Criterios de filtro
    zones: List[str]
    property_types: List[str]
    min_discount_pct: float
    max_price: Optional[float]
    min_confidence: float

    # Canales
    channels: List[AlertChannel]

    # Frecuencia
    frequency: str  # immediate, daily_digest, weekly

@dataclass
class Alert:
    id: str
    user_id: str
    opportunity_type: str
    priority: AlertPriority

    title: str
    summary: str
    details: dict

    property_url: str
    created_at: datetime
    sent_at: Optional[datetime]
    read_at: Optional[datetime]

class AlertEngine:
    """Motor de alertas en tiempo real"""

    def __init__(
        self,
        db_connection,
        email_service,
        push_service
    ):
        self.db = db_connection
        self.email = email_service
        self.push = push_service

    async def process_opportunity(
        self,
        opportunity: 'UndervaluedOpportunity'
    ) -> List[Alert]:
        """Procesar oportunidad y generar alertas"""

        # 1. Obtener usuarios con configs que matchean
        matching_users = await self._get_matching_users(opportunity)

        alerts = []

        for user, config in matching_users:
            # 2. Crear alerta
            alert = self._create_alert(user, config, opportunity)
            alerts.append(alert)

            # 3. Guardar en DB
            await self._save_alert(alert)

            # 4. Enviar segun configuracion
            if config.frequency == 'immediate':
                await self._send_alert(alert, config.channels)

        return alerts

    async def _get_matching_users(
        self,
        opportunity: 'UndervaluedOpportunity'
    ) -> List[tuple]:
        """Obtener usuarios cuyas configs matchean la oportunidad"""

        query = """
            SELECT u.id, ac.*
            FROM users u
            JOIN alert_configs ac ON u.id = ac.user_id
            WHERE ac.enabled = true
              AND (
                ac.zones IS NULL OR
                %s = ANY(ac.zones)
              )
              AND (
                ac.property_types IS NULL OR
                %s = ANY(ac.property_types)
              )
              AND ac.min_discount_pct <= %s
              AND (ac.max_price IS NULL OR ac.max_price >= %s)
              AND ac.min_confidence <= %s
        """

        # Ejecutar query con parametros de la oportunidad
        # Retornar lista de (user, config)
        return []

    def _create_alert(
        self,
        user,
        config: AlertConfig,
        opportunity: 'UndervaluedOpportunity'
    ) -> Alert:
        """Crear objeto de alerta"""

        priority = self._calculate_priority(opportunity)

        title = f"Nueva oportunidad: {opportunity.discount_pct}% descuento"
        summary = self._generate_summary(opportunity)

        return Alert(
            id=self._generate_id(),
            user_id=user.id,
            opportunity_type='undervalued',
            priority=priority,
            title=title,
            summary=summary,
            details={
                'property_id': opportunity.property_id,
                'listed_price': opportunity.listed_price,
                'estimated_value': opportunity.estimated_value,
                'discount_pct': opportunity.discount_pct,
                'confidence': opportunity.confidence,
                'urgency_score': opportunity.urgency_score,
            },
            property_url=opportunity.source_url,
            created_at=datetime.utcnow(),
            sent_at=None,
            read_at=None
        )

    def _calculate_priority(
        self,
        opportunity: 'UndervaluedOpportunity'
    ) -> AlertPriority:
        """Determinar prioridad de la alerta"""

        score = (
            opportunity.discount_pct * 2 +
            opportunity.confidence * 50 +
            opportunity.urgency_score * 0.5
        )

        if score > 150:
            return AlertPriority.URGENT
        elif score > 100:
            return AlertPriority.HIGH
        elif score > 70:
            return AlertPriority.MEDIUM
        return AlertPriority.LOW

    def _generate_summary(
        self,
        opportunity: 'UndervaluedOpportunity'
    ) -> str:
        """Generar resumen para la alerta"""

        return f"""
{opportunity.title}

Precio lista: ${opportunity.listed_price:,.0f}
Valor estimado: ${opportunity.estimated_value:,.0f}
Descuento: {opportunity.discount_pct}%

Confianza: {opportunity.confidence * 100:.0f}%
Urgencia: {opportunity.urgency_score:.0f}/100

{opportunity.recommendation}
        """.strip()

    async def _send_alert(
        self,
        alert: Alert,
        channels: List[AlertChannel]
    ):
        """Enviar alerta por los canales configurados"""

        tasks = []

        for channel in channels:
            if channel == AlertChannel.EMAIL:
                tasks.append(self._send_email(alert))
            elif channel == AlertChannel.PUSH:
                tasks.append(self._send_push(alert))
            elif channel == AlertChannel.SMS:
                tasks.append(self._send_sms(alert))
            elif channel == AlertChannel.WEBHOOK:
                tasks.append(self._send_webhook(alert))

        await asyncio.gather(*tasks, return_exceptions=True)

        # Actualizar sent_at
        await self._update_sent_at(alert.id)

    async def _send_email(self, alert: Alert):
        """Enviar alerta por email"""
        user = await self._get_user(alert.user_id)

        subject = f"[{alert.priority.name}] {alert.title}"
        html_body = self._render_email_template(alert)

        await self.email.send(
            to=user.email,
            subject=subject,
            html=html_body
        )

    async def _send_push(self, alert: Alert):
        """Enviar push notification"""
        user = await self._get_user(alert.user_id)

        await self.push.send(
            user_id=user.id,
            title=alert.title,
            body=alert.summary[:100],
            data={
                'type': 'opportunity',
                'opportunity_id': alert.details['property_id'],
                'url': alert.property_url
            }
        )

    async def _save_alert(self, alert: Alert):
        """Guardar alerta en base de datos"""
        pass

    async def _update_sent_at(self, alert_id: str):
        """Actualizar timestamp de envio"""
        pass

    async def _get_user(self, user_id: str):
        """Obtener usuario por ID"""
        pass

    def _render_email_template(self, alert: Alert) -> str:
        """Renderizar template HTML de email"""
        return f"""
        <html>
        <body>
            <h2>{alert.title}</h2>
            <pre>{alert.summary}</pre>
            <a href="{alert.property_url}">Ver propiedad</a>
        </body>
        </html>
        """

    def _generate_id(self) -> str:
        import uuid
        return str(uuid.uuid4())

7. API de Oportunidades

# src/ml/opportunities/api.py
from fastapi import FastAPI, HTTPException, Query, Depends
from typing import List, Optional
from pydantic import BaseModel

from .undervalued_detector import UndervaluedDetector, UndervaluedOpportunity
from .emerging_zones import EmergingZoneDetector, EmergingZone

app = FastAPI(title="Opportunities API", version="1.0.0")

class OpportunityFilter(BaseModel):
    zones: Optional[List[str]] = None
    property_types: Optional[List[str]] = None
    min_discount: float = 10.0
    max_price: Optional[float] = None
    min_confidence: float = 0.7
    limit: int = 20

@app.get("/opportunities/undervalued", response_model=List[dict])
async def get_undervalued_opportunities(
    filters: OpportunityFilter = Depends()
):
    """Obtener propiedades subvaluadas"""

    detector = UndervaluedDetector(
        avm_model=load_avm_model(),
        explainer=load_explainer(),
        min_discount_pct=filters.min_discount,
        min_confidence=filters.min_confidence
    )

    # Obtener propiedades activas
    properties = await get_active_properties(
        zones=filters.zones,
        property_types=filters.property_types,
        max_price=filters.max_price
    )

    opportunities = detector.batch_detect(properties)

    return [
        {
            'property_id': o.property_id,
            'title': o.title,
            'url': o.source_url,
            'listed_price': o.listed_price,
            'estimated_value': o.estimated_value,
            'discount_pct': o.discount_pct,
            'confidence': o.confidence,
            'urgency_score': o.urgency_score,
            'recommendation': o.recommendation,
            'comparables_count': len(o.comparables),
        }
        for o in opportunities[:filters.limit]
    ]

@app.get("/opportunities/emerging-zones", response_model=List[dict])
async def get_emerging_zones(
    min_appreciation: float = Query(10.0, ge=0),
    limit: int = Query(10, ge=1, le=50)
):
    """Obtener zonas emergentes con potencial de apreciacion"""

    detector = EmergingZoneDetector(
        db_connection=get_db(),
        min_appreciation=min_appreciation
    )

    zones = detector.detect_emerging_zones()

    return [
        {
            'zone': z.zone_name,
            'municipality': z.municipality,
            'appreciation_12m': z.appreciation_12m,
            'trend': z.appreciation_trend,
            'avg_price_m2': z.avg_price_m2,
            'investment_score': z.investment_score,
            'risk_level': z.risk_level,
            'drivers': z.drivers,
            'forecast_12m': z.forecast_12m,
        }
        for z in zones[:limit]
    ]

@app.get("/opportunities/{property_id}/analysis")
async def get_property_analysis(property_id: str):
    """Obtener analisis detallado de una propiedad"""

    property_data = await get_property(property_id)
    if not property_data:
        raise HTTPException(status_code=404, detail="Property not found")

    detector = UndervaluedDetector(
        avm_model=load_avm_model(),
        explainer=load_explainer()
    )

    opportunity = detector.detect(property_data, property_data['price'])

    if not opportunity:
        return {
            'is_opportunity': False,
            'message': 'Property is not considered undervalued',
            'estimated_value': None,
        }

    return {
        'is_opportunity': True,
        'listed_price': opportunity.listed_price,
        'estimated_value': opportunity.estimated_value,
        'discount_pct': opportunity.discount_pct,
        'confidence': opportunity.confidence,
        'urgency_score': opportunity.urgency_score,
        'recommendation': opportunity.recommendation,
        'explanation': opportunity.explanation,
        'comparables': opportunity.comparables,
    }

# Alert configuration endpoints
@app.post("/alerts/config")
async def create_alert_config(config: dict, user_id: str = Depends(get_current_user)):
    """Crear configuracion de alertas"""
    pass

@app.get("/alerts")
async def get_user_alerts(
    user_id: str = Depends(get_current_user),
    unread_only: bool = False,
    limit: int = 20
):
    """Obtener alertas del usuario"""
    pass

@app.put("/alerts/{alert_id}/read")
async def mark_alert_read(alert_id: str):
    """Marcar alerta como leida"""
    pass

8. Tests

# src/ml/opportunities/__tests__/test_undervalued.py
import pytest
from ..undervalued_detector import UndervaluedDetector

class TestUndervaluedDetector:

    @pytest.fixture
    def detector(self, mock_avm, mock_explainer):
        return UndervaluedDetector(
            avm_model=mock_avm,
            explainer=mock_explainer,
            min_discount_pct=10.0,
            min_confidence=0.75
        )

    def test_detect_undervalued_property(self, detector):
        property_data = {
            'id': 'test-123',
            'title': 'Casa en Providencia',
            'constructed_area_m2': 180,
            'bedrooms': 3,
            'bathrooms': 2.5,
            'latitude': 20.6736,
            'longitude': -103.3927,
            'neighborhood': 'Providencia',
        }
        listed_price = 4_000_000  # AVM estima 5M

        opportunity = detector.detect(property_data, listed_price)

        assert opportunity is not None
        assert opportunity.discount_pct >= 10
        assert opportunity.confidence >= 0.75

    def test_no_opportunity_fair_price(self, detector):
        property_data = {
            'id': 'test-456',
            'constructed_area_m2': 150,
            'bedrooms': 2,
            'bathrooms': 2,
            'latitude': 20.67,
            'longitude': -103.39,
            'neighborhood': 'Test Zone',
        }
        listed_price = 3_000_000  # Precio justo

        opportunity = detector.detect(property_data, listed_price)

        assert opportunity is None

    def test_urgency_calculation(self, detector):
        # Propiedad recien listada con alto descuento
        property_data = {
            'days_on_market': 3,
            'is_premium_zone': True,
            'description': 'Venta urgente',
        }

        urgency = detector._calculate_urgency(property_data, discount_pct=20)

        assert urgency >= 80

9. Modelo de Datos

-- Tabla de oportunidades detectadas
CREATE TABLE opportunities (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id VARCHAR(32) REFERENCES properties(id),
    opportunity_type VARCHAR(50) NOT NULL,

    listed_price DECIMAL(15,2) NOT NULL,
    estimated_value DECIMAL(15,2) NOT NULL,
    discount_pct DECIMAL(5,2) NOT NULL,

    confidence DECIMAL(3,2) NOT NULL,
    urgency_score DECIMAL(5,2),

    explanation JSONB,
    comparables JSONB,
    recommendation TEXT,

    detected_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP,
    status VARCHAR(20) DEFAULT 'active',

    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_opportunities_property ON opportunities(property_id);
CREATE INDEX idx_opportunities_type ON opportunities(opportunity_type);
CREATE INDEX idx_opportunities_status ON opportunities(status);
CREATE INDEX idx_opportunities_discount ON opportunities(discount_pct DESC);

-- Configuraciones de alertas
CREATE TABLE alert_configs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    name VARCHAR(100) NOT NULL,
    enabled BOOLEAN DEFAULT true,

    zones TEXT[],
    property_types TEXT[],
    min_discount_pct DECIMAL(5,2) DEFAULT 10,
    max_price DECIMAL(15,2),
    min_confidence DECIMAL(3,2) DEFAULT 0.7,

    channels TEXT[] DEFAULT ARRAY['email'],
    frequency VARCHAR(20) DEFAULT 'immediate',

    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_alert_configs_user ON alert_configs(user_id);

-- Alertas enviadas
CREATE TABLE alerts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    opportunity_id UUID REFERENCES opportunities(id),
    config_id UUID REFERENCES alert_configs(id),

    priority SMALLINT DEFAULT 2,
    title VARCHAR(200) NOT NULL,
    summary TEXT,
    details JSONB,

    property_url TEXT,

    created_at TIMESTAMP DEFAULT NOW(),
    sent_at TIMESTAMP,
    read_at TIMESTAMP
);

CREATE INDEX idx_alerts_user ON alerts(user_id);
CREATE INDEX idx_alerts_created ON alerts(created_at DESC);

Anterior: ET-IA-008-avm.md Siguiente: ET-IA-008-reports.md