Proyecto: Monitor Web Autónomo
En este proyecto construimos un agente autónomo que monitorea el precio de Bitcoin como demostración de un sensor digital. El agente corre como un proceso persistente de la BEAM, percibe datos de una API, y reacciona ante cambios significativos. El código se encuentra en apps/web_sensor.
Estructura del Proyecto
apps/web_sensor/
├── lib/
│ ├── sensor_web.ex # Sensor (Fetch + Parse)
│ ├── agente_monitor.ex # Agente (Decide + Act)
│ └── web_sensor_app.ex # Application con Supervisor
├── mix.exs
├── test/
│ └── web_sensor_test.exs
└── README.md
Configuración (mix.exs)
defmodule WebSensor.MixProject do
use Mix.Project
def project do
[
app: :web_sensor,
version: "0.1.0",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger],
mod: {WebSensorApp, []}
]
end
defp deps do
[
{:req, "~> 0.5.0"},
{:floki, "~> 0.37.0"}
]
end
end
Código Fuente
Sensor (lib/sensor_web.ex)
El sensor se encarga exclusivamente de percibir: obtener los datos crudos del entorno y transformarlos en un percepto estructurado.
defmodule SensorWeb do
@moduledoc """
Componente de percepción. Transforma datos del entorno
digital (APIs, HTML) en perceptos estructurados.
"""
@doc """
Obtiene el precio actual de Bitcoin desde CoinGecko.
"""
def precio_bitcoin do
url = "https://api.coingecko.com/api/v3/simple/price" <>
"?ids=bitcoin&vs_currencies=usd&include_24hr_change=true"
case Req.get(url) do
{:ok, %{status: 200, body: body}} ->
btc = body["bitcoin"]
{:ok, %{
asset: "bitcoin",
precio_usd: btc["usd"],
cambio_24h: Float.round(btc["usd_24h_change"] || 0.0, 2),
timestamp: DateTime.utc_now()
}}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, exception} ->
{:error, Exception.message(exception)}
end
end
@doc """
Sensor genérico: extrae texto de una página web usando un selector CSS.
"""
def scrape_texto(url, selector) do
case Req.get(url) do
{:ok, %{status: 200, body: html}} when is_binary(html) ->
texto =
html
|> Floki.parse_document!()
|> Floki.find(selector)
|> Floki.text()
|> String.trim()
{:ok, texto}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, exception} ->
{:error, Exception.message(exception)}
end
end
end
Agente (lib/agente_monitor.ex)
El agente se encarga de decidir y actuar: consulta periódicamente al sensor y reacciona según sus reglas internas.
defmodule AgenteMonitor do
use GenServer
require Logger
@moduledoc """
Agente autónomo que monitorea precios de criptomonedas.
Implementa el ciclo: Percibir → Decidir → Actuar.
"""
defstruct [
:intervalo,
:umbral,
:ultimo_precio,
historial: [],
alertas: 0
]
# --- API Pública ---
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Consulta el estado actual del agente."
def estado, do: GenServer.call(__MODULE__, :estado)
@doc "Consulta el historial de precios observados."
def historial, do: GenServer.call(__MODULE__, :historial)
# --- Callbacks del GenServer ---
@impl true
def init(opts) do
state = %__MODULE__{
intervalo: Keyword.get(opts, :intervalo, 60_000),
umbral: Keyword.get(opts, :umbral, 5.0)
}
Logger.info("🤖 Agente Monitor iniciado")
Logger.info(" 📊 Intervalo: #{div(state.intervalo, 1000)}s")
Logger.info(" 🎯 Umbral de alerta: #{state.umbral}%")
# Primer chequeo inmediato
send(self(), :ciclo_percepcion)
{:ok, state}
end
@impl true
def handle_info(:ciclo_percepcion, state) do
state = ejecutar_ciclo(state)
# Programar siguiente ciclo
Process.send_after(self(), :ciclo_percepcion, state.intervalo)
{:noreply, state}
end
@impl true
def handle_call(:estado, _from, state) do
resumen = %{
ultimo_precio: state.ultimo_precio,
total_observaciones: length(state.historial),
alertas_emitidas: state.alertas,
umbral: state.umbral
}
{:reply, resumen, state}
end
@impl true
def handle_call(:historial, _from, state) do
{:reply, Enum.take(state.historial, 10), state}
end
# --- Lógica del Agente ---
defp ejecutar_ciclo(state) do
case SensorWeb.precio_bitcoin() do
{:ok, percepto} ->
procesar_percepto(state, percepto)
{:error, razon} ->
Logger.error("❌ Fallo en percepción: #{razon}")
state
end
end
defp procesar_percepto(state, %{precio_usd: precio, cambio_24h: cambio} = percepto) do
Logger.info("📊 BTC: $#{precio} | Cambio 24h: #{cambio}%")
# DECISIÓN: ¿El cambio supera nuestro umbral?
state = if abs(cambio) > state.umbral do
# ACCIÓN: Emitir alerta
emitir_alerta(precio, cambio)
%{state | alertas: state.alertas + 1}
else
state
end
# Actualizar modelo interno
%{state |
ultimo_precio: precio,
historial: [percepto | Enum.take(state.historial, 99)]
}
end
defp emitir_alerta(precio, cambio) do
{emoji, direccion} = if cambio > 0, do: {"📈", "SUBIDA"}, else: {"📉", "BAJADA"}
Logger.warning("""
⚠️ ALERTA DE #{direccion} #{emoji}
Precio: $#{precio}
Cambio: #{abs(cambio)}% en 24h
""")
end
end
Ejecución
cd apps/web_sensor
mix deps.get
iex -S mix
Al iniciar, el agente comienza a monitorear automáticamente:
🤖 Agente Monitor iniciado
📊 Intervalo: 60s
🎯 Umbral de alerta: 5.0%
📊 BTC: $64523.0 | Cambio 24h: -1.2%
Puedes interactuar con el agente en cualquier momento:
# Ver estado actual
AgenteMonitor.estado()
# => %{ultimo_precio: 64523.0, total_observaciones: 5, alertas_emitidas: 0, umbral: 5.0}
# Ver historial reciente
AgenteMonitor.historial()
# => [%{asset: "bitcoin", precio_usd: 64523.0, ...}, ...]
|
El agente sobrevive a errores de red automáticamente gracias al supervisor. Si la API de CoinGecko no responde, el agente loguea el error y vuelve a intentar en el siguiente ciclo sin detenerse. |
Posibles Mejoras
-
Múltiples activos: Monitorear no solo Bitcoin, sino también Ethereum, SOL, etc. usando un pool de procesos.
-
Persistencia: Guardar el historial en una base de datos (ETS, SQLite o PostgreSQL) para análisis posterior.
-
Notificaciones reales: Enviar alertas por Telegram, Slack o email usando librerías como
Swoosh. -
Análisis estadístico: Usar las herramientas de Estadística Descriptiva para calcular media móvil, volatilidad y detectar tendencias.
-
Multi-agente: Combinar con el sensor de noticias (RSS) para correlacionar movimientos de precio con eventos del mundo real.