--- id: "ET-ML-opportunities" title: "Especificacion Tecnica - Deteccion de Oportunidades de Inversion" type: "Technical Specification" epic: "IAI-008" status: "Draft" version: "1.0" project: "inmobiliaria-analytics" created_date: "2026-01-04" updated_date: "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 ```yaml 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 ```python # 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 ```python # 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 ```python # 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"""

{alert.title}

{alert.summary}
Ver propiedad """ def _generate_id(self) -> str: import uuid return str(uuid.uuid4()) ``` --- ## 7. API de Oportunidades ```python # 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 ```python # 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 ```sql -- 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](./ET-IA-008-avm.md) **Siguiente:** [ET-IA-008-reports.md](./ET-IA-008-reports.md)