--- 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.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)