SSD Nodes Learn 🎉 VPS desde $4.99/mes
Guías Matt ConnorPor Matt Connor

Agente de análisis bursátil autohospedado en un VPS

Crea un agente de análisis bursátil en un VPS con feed de mercado, DuckDB, temporizador systemd al cierre y un filtro LLM con Claude API.

Qué es un agente autohospedado para investigar acciones

Un agente autohospedado para investigar acciones es un programa pequeño que se ejecuta en un servidor propio, obtiene datos de mercado según un calendario, los conserva en una base de datos local, los filtra y pide a un modelo de lenguaje grande (LLM) que redacte los cambios detectados. Lee y filtra datos. No realiza operaciones, y nada de esta guía constituye asesoramiento financiero.

Dos personas que desarrollen esto desde cero pueden elegir bibliotecas diferentes y aun así terminar con las mismas cuatro partes: un feed que proporciona precios y fundamentales, un almacén local que conserva cada fila obtenida, una tarea que actualiza el almacén mediante un temporizador y una capa LLM que convierte las filas restantes en texto. Esta guía construye esa estructura con Python, DuckDB, un temporizador de systemd y la API de Claude (interfaz de programación de aplicaciones). La ejecución es una tarea independiente, con modos de fallo distintos, y debe ubicarse en un VPS configurado para bots de trading.

Las cuatro partes y la función de cada una

El feed es la única parte que se comunica con el exterior. Sabe cómo solicitar un ticker y un intervalo de fechas, y cómo devolver filas. Todo lo que viene después lee de la base de datos en lugar de consultar el feed. Por eso, una interrupción del feed sólo le cuesta un día de datos nuevos y no deja una pantalla inutilizable.

El almacén es el objetivo principal de todo el proceso. Un cierre diario que no registró normalmente se puede volver a obtener más adelante. Una cotización intradía, una estimación anterior a su revisión o un dato de fundamentales anterior a su reformulación no se pueden recuperar. El almacén permite crear un registro de lo que los datos indicaban el día en que lo indicaban.

El programador decide cuándo se ejecuta la actualización. En un servidor, se trata de un temporizador de systemd. Por eso la VPS es más importante aquí que el código.

La capa LLM lee un bloque breve de texto generado por SQL y redacta un resumen. Nunca se conecta a la base de datos ni construye la consulta. Si el modelo genera el SQL, un token incorrecto se convierte en una cifra incorrecta dentro de una frase fluida, sin nada con lo que compararla. Si SQL genera las cifras, el modelo sólo puede equivocarse en la redacción, y puede comprobar la redacción comparándola con las filas que se le enviaron.

Por qué ejecutarlo en un VPS en lugar de un portátil

El programador es la razón principal. El cierre del mercado estadounidense a las 16:00, hora de Nueva York, corresponde a las 22:00 en Berlín y a las 04:00 de la mañana siguiente en Yakarta. Un portátil está suspendido en ambos momentos. Una ejecución perdida cuesta más que una nota tardía: normalmente se pueden volver a obtener las barras diarias más adelante, pero no se puede recuperar la información que se revisa, por lo que la interrupción del registro es permanente.

La segunda razón es menos importante, pero también es real. El servidor almacena una sola clave de API en un solo archivo, propiedad de un único usuario del sistema sin shell de inicio de sesión, y la utiliza un solo trabajo. Es mucho más difícil conseguir esta configuración en el portátil que también usa para navegar por Internet. Mantenga la clave fuera del código y de la entrada del modelo. Este es el tema de mantener las claves de API fuera de un agente de IA.

Dimensionamiento: disco, RAM y tokens

El disco es la parte sencilla. Una barra diaria es una fila por ticker y día de negociación, y un año bursátil en EE. UU. tiene unos 252 días.

ChartRows in the prices table by watchlist size, at 252 trading days a year
The data behind this chart
[
  {
    "label": "20 tickers",
    "rows_after_1y": "5,040",
    "rows_after_10y": "50,400"
  },
  {
    "label": "100 tickers",
    "rows_after_1y": "25,200",
    "rows_after_10y": "252,000"
  },
  {
    "label": "500 tickers",
    "rows_after_1y": "126,000",
    "rows_after_10y": "1,260,000"
  }
]

Veinte tickers son 5,040 filas después de un año. La línea 500 tickers alcanza 1,260,000 filas después de diez años. Cada fila contiene una fecha y varios valores de tipo double. DuckDB almacena las columnas comprimidas, por lo que el tamaño se mide en decenas de megabytes, no en gigabytes. No confíe en esa estimación, incluida la mía. Ejecute du -h /opt/research/data/market.duckdb después de la primera carga histórica y use su propio valor.

La RAM es el punto débil de un VPS pequeño. De forma predeterminada, DuckDB utiliza una parte importante de la memoria de la máquina y todos sus núcleos para una sola consulta. Esto es adecuado en un servidor de análisis, pero no en una máquina con 2 GB que también ejecuta otros servicios. Una agregación sobre toda la tabla de precios puede hacer que el kernel termine el proceso. En ese caso, systemd informa Main process exited, code=killed, status=9/KILL y journalctl -k muestra la terminación por falta de memoria. Establezca memory_limit y threads de forma explícita. La consulta será más lenta en lugar de terminarse.

Los tokens deben medirse, no estimarse. Cada respuesta de la Messages API contiene un objeto usage con input_tokens y output_tokens. Escriba ambos valores en una tabla en cada llamada. Después de una semana conocerá el volumen real. Luego podrá multiplicarlo por el precio que indique el modelo el día de la consulta. Hay dos aspectos suficientemente estables para planificar. Los tokens de salida tienen un precio superior al de los tokens de entrada en todos los modelos de Claude. Por eso, limitar la nota a 200 palabras reduce más la factura que recortar los datos enviados. La caché de prompts tampoco ayuda en una tarea que se ejecuta una vez al día, porque su duración se mide en minutos. Cuando llega la siguiente ejecución, el bloque almacenado en caché ya ha caducado y se vuelve a pagar el precio completo de entrada. La caché resulta útil cuando una ejecución realiza muchas llamadas sobre el mismo bloque grande de texto.

Instalar los componentes

sudo apt update
sudo apt install -y python3-venv
sudo useradd --system --create-home --home-dir /opt/research --shell /usr/sbin/nologin research
sudo -u research python3 -m venv /opt/research/venv
sudo -u research /opt/research/venv/bin/pip install duckdb pandas yfinance anthropic
sudo install -d -o research -g research -m 750 /opt/research/data

Compruebe la instalación antes de escribir código:

sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'

Esto muestra ok. Si omitió el entorno virtual y ejecutó pip install contra el Python del sistema, Ubuntu 24.04 se detiene con error: externally-managed-environment porque la distribución es propietaria de /usr/lib/python3 y no permite que pip escriba allí. El venv no es una cuestión de cortesía. Es el único directorio que pip puede modificar.

La clave de API debe estar en un archivo que el usuario del servicio pueda leer y al que nadie más tenga acceso:

sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/env

Escriba una línea en el archivo, sin comillas ni export, porque systemd analiza este archivo directamente en lugar de pasarlo a un shell:

ANTHROPIC_API_KEY=sk-ant-your-key-here

El almacén: dos tablas

# /opt/research/store.py
import duckdb

DB = '/opt/research/data/market.duckdb'

SCHEMA = [
    """
    CREATE TABLE IF NOT EXISTS prices (
      ticker VARCHAR,
      day    DATE,
      open   DOUBLE,
      high   DOUBLE,
      low    DOUBLE,
      close  DOUBLE,
      volume BIGINT,
      PRIMARY KEY (ticker, day)
    )
    """,
    """
    CREATE TABLE IF NOT EXISTS runs (
      started_at    TIMESTAMPTZ,
      model         VARCHAR,
      input_tokens  BIGINT,
      output_tokens BIGINT,
      hits          BIGINT
    )
    """,
]

def connect(read_only=False):
    con = duckdb.connect(DB, read_only=read_only)
    con.execute("SET memory_limit='512MB'")
    con.execute('SET threads=2')
    if not read_only:
        for statement in SCHEMA:
            con.execute(statement)
    return con

La clave primaria de (ticker, day) permite repetir la actualización de forma segura. INSERT OR REPLACE sobrescribe una fila que ya existe para ese ticker y ese día, por lo que ejecutar el relleno dos veces no duplica la tabla. Sin esta clave, una segunda ejecución después de un bloqueo duplica silenciosamente cada barra, y todos los promedios calculados después son incorrectos. No aparece ningún mensaje de error que lo indique.

DuckDB permite que un solo proceso abra el archivo para escritura. Un segundo escritor falla de inmediato con Could not set lock on file, seguido del PID que mantiene el archivo abierto. En la práctica, suele ser el shell interactivo duckdb que quedó abierto en otra terminal. Los lectores pasan read_only=True. Por eso connect acepta la opción. Si varios procesos necesitan escribir realmente al mismo tiempo, se necesita otro motor: SQLite en modo WAL permite que los lectores trabajen mientras un escritor confirma los cambios, y un tiempo de espera para operaciones ocupadas hace que los demás escritores esperen en lugar de fallar. DuckDB frente a SQLite para una carga de trabajo de servidor compara ambos motores, y ejecutar SQLite en producción en un VPS explica la configuración necesaria para que WAL funcione correctamente.

El trabajo de actualización

# /opt/research/refresh.py
import sys
import pandas as pd
import yfinance as yf
from store import connect

TICKERS = ['AAPL', 'MSFT', 'KO', 'SAP', 'TSM']
FIRST_DAY = '2016-01-01'
COLS = ['ticker', 'day', 'open', 'high', 'low', 'close', 'volume']

def fetch(ticker, start):
    df = yf.Ticker(ticker).history(start=start, auto_adjust=True)
    if df.empty:
        return None
    df = df.reset_index()
    stamps = pd.to_datetime(df['Date'])
    if stamps.dt.tz is not None:
        stamps = stamps.dt.tz_localize(None)
    df['day'] = stamps.dt.date
    df['ticker'] = ticker
    df = df.rename(columns={'Open': 'open', 'High': 'high', 'Low': 'low',
                            'Close': 'close', 'Volume': 'volume'})
    return df[COLS]

def main():
    con = connect()
    empty = 0
    for ticker in TICKERS:
        last = con.execute('SELECT max(day) FROM prices WHERE ticker = ?',
                           [ticker]).fetchone()[0]
        rows_df = fetch(ticker, str(last) if last else FIRST_DAY)
        if rows_df is None:
            print(ticker + ': feed returned no rows', file=sys.stderr)
            empty += 1
            continue
        con.register('rows_df', rows_df)
        con.execute('INSERT OR REPLACE INTO prices '
                    'SELECT ticker, day, open, high, low, close, volume FROM rows_df')
        print(ticker + ': ' + str(len(rows_df)) + ' rows')
    con.close()
    if empty == len(TICKERS):
        print('every ticker returned nothing: the feed is broken', file=sys.stderr)
        sys.exit(1)

main()

Ejecútelo manualmente una vez:

sudo -u research /opt/research/venv/bin/python /opt/research/refresh.py

La primera ejecución recupera varios años y muestra una línea por cada ticker, con un recuento de miles. Ejecútelo de nuevo un minuto después. Cada línea debe mostrar 1 o 2 filas, porque el trabajo comienza en el último día almacenado. Esta segunda ejecución es la prueba real: si los recuentos siguen siendo de miles, max(day) no devuelve datos y la inserción reescribe todo el historial cada noche.

La comprobación de df.empty es la línea más importante del archivo. Ticker.history() no genera una excepción cuando el símbolo es incorrecto o ya no cotiza. Muestra una advertencia que indica que el símbolo podría haber dejado de cotizar y que no se encontraron datos de precios (el texto exacto cambia entre versiones de la biblioteca), y devuelve un DataFrame vacío. Un trabajo sin esta comprobación no escribe nada, termina con código 0 y systemd muestra una ejecución correcta, mientras la tabla deja de crecer sin avisar. El problema se detecta semanas después, cuando una pantalla devuelve las mismas filas todos los días.

El tratamiento de las marcas de tiempo también tiene un motivo. El índice que devuelve el feed puede incluir la zona horaria de la bolsa, y eliminar el desplazamiento no equivale a convertir la hora a UTC. Una sesión de Tokio registrada a medianoche en hora local se convierte en el día natural anterior en UTC. Por tanto, una conversión a UTC desplaza silenciosamente cada barra japonesa un día hacia atrás y rompe la clave primaria. tz_localize(None) conserva la fecha de sesión de la bolsa, que es lo que representa una barra diaria.

El temporizador que se ejecuta al cierre del mercado

El mercado estadounidense cierra a las 16:00, hora de Nueva York, y las últimas operaciones tardan unos minutos en liquidarse, por lo que el trabajo se ejecuta a las 16:20. Especifique esa hora de Nueva York, no la hora UTC. Nueva York está en UTC menos 5 en invierno y en UTC menos 4 en verano, por lo que un temporizador configurado con una hora UTC fija se desplaza una hora dos veces al año y empieza a ejecutarse antes del cierre. systemd 252 y posteriores aceptan directamente una zona horaria en OnCalendar, y Ubuntu 24.04 incluye la versión 255. Confirme la versión instalada con systemctl --version.

# /etc/systemd/system/research-refresh.service
[Unit]
Description=Refresh market data and run the daily screen
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=research
Group=research
WorkingDirectory=/opt/research
EnvironmentFile=/etc/research/env
ExecStart=/opt/research/venv/bin/python /opt/research/refresh.py
ExecStart=/opt/research/venv/bin/python /opt/research/screen.py
# /etc/systemd/system/research-refresh.timer
[Unit]
Description=Run the refresh after the US market close

[Timer]
OnCalendar=Mon-Fri 16:20 America/New_York
Persistent=true
RandomizedDelaySec=180

[Install]
WantedBy=timers.target

Type=oneshot es el único tipo de servicio que acepta más de un ExecStart y los ejecuta en orden. Se detiene si alguno termina con un código distinto de cero. Este es exactamente el comportamiento necesario: una actualización fallida no debe ir seguida de una pantalla con datos obsoletos. Persistent=true es importante en un VPS que se reinicia para instalar actualizaciones del kernel. Si se reinicia a las 16:15 sin esta opción, la ejecución se pierde. Con ella, el trabajo se ejecuta en cuanto la máquina vuelve a estar disponible.

sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timer

list-timers debería mostrar una columna NEXT con la próxima ejecución entre semana, convertida a la hora local del propio servidor. Una lista vacía significa que el temporizador no está habilitado o que a la unidad le falta la sección [Install]. En ese caso, enable no tenía ninguna unidad a la que enlazar mediante timers.target.

La pantalla: SQL primero, el modelo al final

SQL es exacto y no tiene coste por ejecución. El modelo no. Por eso, la consulta reduce el conjunto de datos y sólo los registros que quedan se envían al modelo. Guarde esto como /opt/research/screen.sql.

WITH ma AS (
  SELECT ticker, day, close,
         avg(close) OVER w20 AS ma20,
         avg(close) OVER w50 AS ma50,
         row_number() OVER (PARTITION BY ticker ORDER BY day) AS n
  FROM prices
  WINDOW
    w20 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 19 PRECEDING AND CURRENT ROW),
    w50 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
)
SELECT ticker, day, close, round(ma20, 2) AS ma20, round(ma50, 2) AS ma50
FROM ma
WHERE n > 50 AND ma20 > ma50
ORDER BY day DESC, ticker
LIMIT 20;

El filtro n > 50 no es decorativo. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW calcula el promedio de las filas disponibles, por lo que la tercera fila de un ticker devuelve el promedio de tres días y aun así lo denomina ma50. Si lo compara con ma20, crea un cruce al principio del historial de cada ticker que nunca ocurrió. Filtrar por el número de fila elimina las filas en las que la ventana no estaba completa.

Lo que ve el modelo y lo que nunca ve

# /opt/research/screen.py
from anthropic import Anthropic
from store import connect

SYSTEM = (
    'You are a research assistant. Use only the rows in the message. '
    'If a number is not in the rows, say that it is not available. '
    'Do not give investment advice, price targets or buy and sell calls. '
    'Write at most 200 words.'
)

con = connect()
sql = open('/opt/research/screen.sql').read()
rows = con.execute(sql).fetchall()
block = '\n'.join(' / '.join(str(v) for v in row) for row in rows)

client = Anthropic(max_retries=5)   # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
    model='claude-sonnet-5',
    max_tokens=600,
    system=SYSTEM,
    messages=[{'role': 'user', 'content': 'Screen hits, ticker / day / close / ma20 / ma50:\n' + block}],
)

print(resp.content[0].text)
con.execute('INSERT INTO runs VALUES (now(), ?, ?, ?, ?)',
            ['claude-sonnet-5', resp.usage.input_tokens,
             resp.usage.output_tokens, len(rows)])
con.close()

El límite de palabras del mensaje del sistema limita la parte más costosa de la factura. La instrucción de usar sólo las filas proporcionadas es la que debe verificar en lugar de aceptar sin más: elimine una columna del bloque, ejecútelo de nuevo y lea la salida. Si sigue apareciendo un dato de esa columna, el modelo completó la información que faltaba y el prompt no es suficientemente estricto. La prueba tarda dos minutos y es la única forma fiable de comprobarlo.

El modelo nunca ve la clave de API, nunca ve la ruta de la base de datos y nunca ejecuta una consulta. Recibe filas y devuelve texto. Ese límite hace que la salida se pueda comprobar, porque todos los números de la nota también deberían aparecer en el bloque enviado y puede compararlos línea por línea. Para obtener más información sobre el uso de prompts, uso de Claude para análisis financiero explica con más detalle qué información puede leer bien el modelo.

Una ejecución completa

A las 16:20, hora de Nueva York, el temporizador inicia el servicio. refresh.py consulta el feed para cada ticker desde el último día almacenado, escribe una o dos barras nuevas por cada uno y muestra una línea por ticker. screen.py abre el mismo archivo, ejecuta la consulta de media móvil y recibe varias filas. Esas filas se convierten en un bloque de texto de unos cientos de tokens. Una llamada a la API las convierte en una nota breve. La nota se escribe en el journal y una fila se guarda en runs con los recuentos de tokens y el número de coincidencias.

journalctl -u research-refresh.service -n 50 --no-pager

Un log correcto contiene una línea por ticker, después la nota y, por último, research-refresh.service: Deactivated successfully. Después de una semana, consulte sus propios costes en la base de datos:

SELECT count(*) AS runs,
       sum(input_tokens)  AS in_tokens,
       sum(output_tokens) AS out_tokens
FROM runs;

Multiplique esos totales por los precios por millón de tokens que indique su modelo el día que los consulte. Así obtiene el importe real en lugar de una estimación ajena. Los precios publicados cambian. La operación no.

Por qué los backtests se sobreajustan y cómo observarlo

Reescriba el filtro como una función de las dos longitudes de ventana, recorra una cuadrícula de pares y ordénelos por rentabilidad. El mejor par parecerá excelente. Ese es el problema, no el resultado. Una cuadrícula de 200 pares representa 200 experimentos, y usted se quedó con el que tuvo más suerte.

Puede observarlo en diez minutos. Divida el almacén en dos partes por fecha. Recorra la cuadrícula sólo en la primera mitad y anote el ganador. Recorra la misma cuadrícula en la segunda mitad. Si los dos ganadores son muy distintos, los parámetros están ajustándose al ruido. Un par que sólo gana en la mitad con la que se ajustó no dice nada sobre el día siguiente.

El sesgo de supervivencia es peor que el sobreajuste porque el ajuste no puede corregirlo. Su lista de símbolos contiene los miembros actuales del índice, por lo que sólo incluye las empresas que sobrevivieron. Si solicita al feed un símbolo excluido de cotización en 2019, devuelve un marco vacío. Esa empresa nunca entra en el almacén ni en la prueba. Todos los backtests que ejecuta ya han excluido los casos fallidos.

Los fundamentales reformulados rompen la línea temporal. La cifra de ingresos que la API devuelve hoy para un trimestre de 2019 no siempre es la cifra publicada en 2019. Un filtro que combina los fundamentales actuales con precios de 2019 utiliza información que no existía entonces. Los precios suelen ser seguros en este aspecto. Los fundamentales normalmente no lo son.

Los precios ajustados cambian bajo sus pies. Con auto_adjust=True, los cierres se ajustan hacia atrás por dividendos y desdoblamientos. Por eso, la misma consulta ejecutada el mes que viene devuelve un historial ligeramente distinto. Almacenar las filas que realmente utilizó permite reproducir el resultado. También es otra razón para que exista el almacén local.

El backtest también ignora las comisiones y el deslizamiento, y supone que su orden no mueve el precio. Estos aspectos pertenecen a la ejecución, que queda fuera del alcance de esta guía y se explica en ejecutar bots de trading en un VPS.

Modos de fallo y mensajes que verá

error: externally-managed-environment al ejecutar pip. Está fuera del entorno virtual. Ejecute /opt/research/venv/bin/pip mediante su ruta completa.

Could not set lock on file, seguido de un PID. Otro proceso mantiene abierto el archivo de DuckDB para escritura, normalmente un shell interactivo que olvidó cerrar. Ciérrelo o abra la segunda conexión con read_only=True.

Main process exited, code=killed, status=9/KILL en systemctl status. El kernel terminó el trabajo por falta de memoria. Confírmelo con journalctl -k | grep -i oom y reduzca memory_limit en store.py.

Una ejecución correcta que no escribe nada. systemctl status lee active (exited) y la tabla no ha crecido. El feed devolvió data frames vacíos. Este fallo es el más difícil de detectar, por lo que debe hacer que el trabajo termine con un código distinto de cero cuando todos los tickers vuelvan vacíos.

El temporizador se ejecutó durante un festivo bursátil. systemd no conoce el calendario de la bolsa, por lo que Mon-Fri incluye los festivos. La ejecución se produce, el feed no contiene datos nuevos y el trabajo debe tratarlo como una situación normal, no como un error.

429 de la API. Ha superado un límite de frecuencia. El SDK de Anthropic reintenta automáticamente con espera progresiva y Anthropic(max_retries=5) aumenta el número de intentos. Si sigue fallando todos los días, el trabajo está solicitando demasiado en una sola ráfaga.

Lo que esto no es

Esto es un asistente de investigación. Un modelo que resume una presentación genera una interpretación de esa presentación y puede equivocarse con seguridad sobre una cifra impresa en el texto. Por eso, cada cifra de la nota debe poder rastrearse hasta una fila que usted haya enviado. Considere el resultado una lista breve de elementos que debe leer por su cuenta. Esto no constituye asesoramiento financiero ni es una señal.

Las pruebas retrospectivas son útiles para descartar ideas y poco fiables para confirmarlas. Una estrategia que falla con sus propios datos está realmente descartada. Una estrategia que supera la prueba sólo ha sobrevivido a sus datos. Es una afirmación mucho más limitada de lo que parece a la 1 a. m.

La ejecución queda deliberadamente fuera del alcance. Las órdenes y las credenciales del bróker tienen un perfil de riesgo distinto al de un sistema de investigación de sólo lectura. Mezclarlos coloca las claves de trading en la misma máquina que una solicitud para un LLM. Si quiere ver cómo encaja este patrón junto a otras opciones que merece la pena ejecutar en su propio servidor, los agentes de IA autoalojados que merece la pena ejecutar ofrecen una visión más amplia.

FAQ

¿Necesito un feed de datos de mercado de pago?

No para un prototipo. Un feed no oficial gratuito sirve para conocer la estructura del sistema, pero fallará porque depende de un sitio web que no tiene ninguna obligación con usted. Normalmente falla devolviendo frames vacíos en lugar de una excepción, por lo que el job debe comprobar el número de filas. Cambie a un feed de pago con una API documentada y una dirección de soporte cuando los datos empiecen a alimentar una decisión. El almacén de datos es lo que hace barato ese cambio: sólo cambia la función de obtención, mientras que la programación, el esquema y la pantalla permanecen igual.

¿Debo guardar los precios en SQLite o DuckDB?

DuckDB es columnar y está diseñado para recorrer muchas filas y calcular un agregado, que es exactamente lo que hace una media móvil sobre diez años de barras. SQLite está orientado a filas y funciona mejor con muchas lecturas y escrituras pequeñas desde varios procesos al mismo tiempo. Para un único job programado que añade unos cientos de filas y después recorre millones, DuckDB es la opción más adecuada. Si varios procesos deben escribir al mismo tiempo, SQLite en modo WAL permite que los lectores trabajen mientras un escritor confirma los cambios, y un tiempo de espera para operaciones ocupadas hace que los demás escritores esperen en lugar de fallar directamente.

¿Cuánto cuestan las llamadas al LLM al mes?

Registre usage.input_tokens y usage.output_tokens de cada respuesta en una tabla. Después multiplique los totales semanales por el precio por millón de tokens que indique el modelo el día que haga la consulta. Esa es la única cifra que seguirá siendo válida el próximo trimestre. Una ejecución diaria sobre una pantalla breve implica pocas llamadas, y la longitud de la nota es la parte que puede controlar: limitar el resumen a 200 palabras ahorra más que enviar menos filas, porque los tokens de salida tienen un precio superior al de los tokens de entrada en todos los modelos de Claude.

¿Por qué mi job terminó correctamente pero no escribió filas nuevas?

Hay dos causas habituales. El mercado estaba cerrado porque una programación de systemd Mon-Fri incluye los festivos bursátiles. O el feed devolvió un frame vacío para cada ticker, algo que las bibliotecas cliente suelen comunicar mediante una advertencia impresa en lugar de una excepción. Por eso el proceso termina igualmente con el código 0 y systemd muestra una ejecución correcta. Distinga ambos casos comparando SELECT max(day) FROM prices con el último día real de negociación y haga que el job termine con un estado distinto de cero cuando todos los tickers devuelvan un resultado vacío.

¿Puede el agente decidir qué comprar?

No. Intentar construirlo para que lo haga es una de las causas de fallo de estos proyectos. El modelo no tiene acceso al mercado, no conoce sus posiciones ni su situación fiscal y no puede comprobar sus propios cálculos con ninguna fuente. Su utilidad está en leer una gran cantidad de texto e indicarle qué pocos elementos merecen su atención ese día. Nada de lo que escriba constituye asesoramiento financiero. La decisión y la responsabilidad correspondiente siguen siendo suyas.