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