🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1249 lines
37 KiB
Markdown
1249 lines
37 KiB
Markdown
---
|
|
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"""
|
|
<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
|
|
|
|
```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)
|