Lección 7.2.2 · 70 min

VPS, nube y latencia; CI/CD con GitHub Actions

La distancia entre el bot y el mercado tiene un suelo que ningún proveedor puede bajar: la luz en la fibra recorre unos 200 kilómetros por milisegundo. Esta lección calcula ese suelo para pares de ciudades reales, lo compara con latencias medidas y decide con números cuándo hace falta acercarse. Después monta el camino del código al servidor con GitHub Actions: un workflow validado con reglas de seguridad de la documentación oficial y un runner local que lo ejecuta sobre un repositorio de prueba.

Fig. — Suelo físico de la latencia de ida y vuelta entre pares de ciudades con mercados o regiones de nube: luz en fibra (c/1,47) por la ortodrómica, barra, y en el vacío, raya
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

Dónde corre un bot

La lección 7.2.1 dejó el bot en una imagen que corre igual en cualquier máquina con Docker. Esta decide qué máquina y cómo llega cada versión a ella. Hay cuatro opciones, y la pregunta que las ordena no es el precio sino el horizonte de la estrategia: cuánto vale un milisegundo para ella.

DóndeQué se ganaQué se pierdePara qué horizonte
Casacoste cero, control totalluz, router, IP dinámica, cortesresearch, paper, diaria con supervisión
VPSmáquina siempre encendida, IP fija, precio fijo al mesrecursos compartidos con otros clientesdiaria e intradía de minutos
Nube (región del broker)regiones junto a las pasarelas, servicios gestionadosfacturación por uso, más piezasintradía de segundos
Colocaciónmicrosegundos al motor de casamientocontratos con el mercado, hardware propiootro negocio

Una estrategia que rebalancea al cierre no gana nada con 50 ms menos: el precio de la orden de cierre es el mismo para todos. Una que reacciona a un cambio del libro en segundos sí, y la lección 4.3.2 midió cuánto se pierde con cada perfil de conexión. Lo que casi todas necesitan es otra cosa: que la máquina esté encendida, con IP fija (las listas blancas de IP de los brokers y exchanges la exigen, lección 4.4.5) y sin depender de la fibra de casa. Por eso el VPS es la opción por defecto y la nube se elige por la región, no por la marca:

sínosínoHorizonteDías u horasVPS cualquieraSegundosRegión del brokerColocación
Fuente Mermaid
flowchart TB
  A[Horizonte] --> B{Días u horas}
  B -->|sí| V[VPS cualquiera]
  B -->|no| C{Segundos}
  C -->|sí| R[Región del broker]
  C -->|no| K[Colocación]

Los proveedores publican dónde están sus regiones. Consultadas el 23 de octubre de 2026 en su documentación: AWS tiene us-east-1 (Norte de Virginia), eu-central-1 (Fráncfort), eu-west-2 (Londres) y eu-south-2 (España, que hay que activar expresamente); Google Cloud, us-east4 (Ashburn, Virginia), europe-west3 (Fráncfort) y europe-southwest1 (Madrid); Azure, Spain Central (Madrid); Hetzner Cloud, Falkenstein y Núremberg (Alemania), Helsinki, Ashburn, Hillsboro (Oregón) y Singapur. Los precios de AWS, Google Cloud y Azure dependen de la región, del tipo de máquina y del tráfico de salida, y cada uno tiene su calculadora oficial; la página de Hetzner Cloud anunciaba ese día planes desde 5,99 € al mes sin IVA (compartidos, «Cost-Optimized»), 6,49 € («Regular Performance») y 43,49 € con vCPU dedicadas («General Purpose»). No hay aquí recomendación de proveedor: hay una lista de preguntas (región, IP fija, precio de la salida de datos, copias, soporte) que la lección 7.2.4 completa con la continuidad.

El suelo de la latencia

Ninguna oferta comercial cambia la física. La luz en el vacío viaja a 299.792,458 km/s; en el vidrio de una fibra, a esa velocidad dividida por el índice de refracción, en torno a 1,47 (el valor que usó la lección 4.3.2): unos 204.000 km/s, dos tercios de la del vacío. La distancia mínima entre dos puntos de la Tierra es la ortodrómica, el arco de círculo máximo, que se calcula con la fórmula del semiverseno a partir de latitudes φ\varphi y longitudes λ\lambda:

d=2Rarcsin⁡sin⁡2φ2−φ12+cos⁡φ1cos⁡φ2sin⁡2λ2−λ12d = 2R \arcsin \sqrt{\sin^2\frac{\varphi_2-\varphi_1}{2} + \cos\varphi_1 \cos\varphi_2 \sin^2\frac{\lambda_2-\lambda_1}{2}}

con R≈6371R \approx 6371 km. Los lugares de la celda son ciudades donde hay mercados o regiones de nube: según ICE, sus centros de colocación dan acceso a ICE Futures en Basildon (Reino Unido) y Chicago y a la NYSE en Mahwah (Nueva Jersey)1Nota 1 · ICE, «ICE Global Network & Colocation» (https://www.ice.com/fixed-income-data-services/access-and-delivery/global-network), consultada el 23 de octubre de 2026: «ICE Colocation provides a highly deterministic path with the lowest latency to ICE Futures (Basildon, Chicago), as well as National Market System (NMS) and NYSE (Mahwah)».; Fráncfort y Ashburn son regiones de AWS, Google Cloud y Hetzner. Las coordenadas son las aproximadas del centro de cada localidad: una diferencia de unos kilómetros no cambia el resultado.

IN [01]python
import math

C = 299_792.458                    # km/s en el vacío
N_FIBRA = 1.47
LUGAR = {
    "Madrid": (40.4168, -3.7038),
    "Fráncfort": (50.1109, 8.6821),
    "Basildon": (51.5761, 0.4887),
    "Mahwah": (41.0887, -74.1438),
    "Ashburn": (39.0438, -77.4874),
    "Chicago": (41.8781, -87.6298),
}

def ortodromica(a, b, r=6371.0):
    (f1, l1), (f2, l2) = (map(math.radians, LUGAR[x]) for x in (a, b))
    s = (math.sin((f2 - f1) / 2) ** 2
         + math.cos(f1) * math.cos(f2) * math.sin((l2 - l1) / 2) ** 2)
    return 2 * r * math.asin(math.sqrt(s))

def suelo_ms(km, n=N_FIBRA):
    return 2 * km / (C / n) * 1000   # ida y vuelta

PARES = [("Ashburn", "Mahwah"), ("Chicago", "Mahwah"),
         ("Fráncfort", "Basildon"), ("Madrid", "Fráncfort"),
         ("Madrid", "Basildon"), ("Basildon", "Mahwah"),
         ("Madrid", "Mahwah")]
SUELO = {}
print(f"{'par':<20}{'km':>7}{'vacío ms':>10}{'fibra ms':>10}")
for a, b in PARES:
    km = ortodromica(a, b)
    SUELO[(a, b)] = suelo_ms(km)
    print(f"{a + '-' + b:<20}{km:>7.0f}{suelo_ms(km, 1):>10.1f}"
          f"{SUELO[(a, b)]:>10.1f}")
OUT [01]Ortodrómica y suelo de ida y vuelta
par                      km  vacío ms  fibra ms
Ashburn-Mahwah          364       2.4       3.6
Chicago-Mahwah         1126       7.5      11.0
Fráncfort-Basildon      598       4.0       5.9
Madrid-Fráncfort       1446       9.6      14.2
Madrid-Basildon        1282       8.6      12.6
Basildon-Mahwah        5591      37.3      54.8
Madrid-Mahwah          5761      38.4      56.5

La figura de la cabecera ordena los pares de menor a mayor. Tres cosas que se leen en ella. Un bot en Madrid que opera en Nueva York empieza con 56,5 ms perdidos antes de hacer nada; uno en Ashburn (Virginia), con 3,6. Entre las dos orillas del Atlántico hay casi 55 ms de fibra como mínimo: ningún proveedor puede ofrecer «baja latencia transatlántica» por debajo de eso. Y la columna del vacío no es un adorno: los enlaces por radio viajan por el aire, casi a la velocidad de la luz en el vacío, y en rutas como Chicago-Nueva Jersey la diferencia entre 7,5 y 11,0 ms es la que separa a quien llega primero. Para un particular, la conclusión práctica es más modesta: el suelo dice qué mejora es imposible y cuándo moverse vale la pena.

Lo que se mide encima del suelo

Una latencia real es el suelo más todo lo demás: el cable no sigue la ortodrómica, cada router añade colas, el sistema operativo y el propio proceso añaden su parte. La lección 4.3.2 describió los seis perfiles de conexión de latencia4_rtt.parquet (datos sintéticos del curso, un ping cada 2 segundos durante 100 minutos). Aquí interesa otra cosa: cuánto se separa cada perfil de su suelo y qué hace su cola con un tiempo de espera.

IN [02]python
import pandas as pd

lat = pd.read_parquet("latencia4_rtt.parquet")
med = lat.groupby("perfil")["rtt_us"].median() / 1000     # ms
for perfil, par in (("retail_espana_nueva_york", ("Madrid", "Mahwah")),
                    ("retail_espana_frankfurt",
                     ("Madrid", "Fráncfort"))):
    s = SUELO[par]
    print(f"{perfil:<26} mediana {med[perfil]:5.1f} ms  "
          f"suelo {s:4.1f} ms  x{med[perfil] / s:.2f}")
OUT [02]Inflado de ruta: mediana medida frente al suelo
retail_espana_nueva_york   mediana  88.5 ms  suelo 56.5 ms  x1.57
retail_espana_frankfurt    mediana  31.1 ms  suelo 14.2 ms  x2.19

La conexión doméstica a Nueva York tarda 1,57 veces su suelo, y la de Fráncfort 2,19. Es lo habitual: la parte fija (rutas que no van en línea recta, saltos entre operadores, el último kilómetro) pesa más cuanto más corto es el trayecto. Para el tiempo de espera, sin embargo, la mediana es casi irrelevante. Un bot que envía una orden y espera la confirmación tiene que decidir cuánto esperar antes de considerarla perdida, y lo que cuenta es la cola:

IN [03]python
PERFILES = ["retail_espana_nueva_york", "retail_espana_frankfurt",
            "vps_nueva_jersey", "nube_misma_region"]
PETICIONES_DIA = 2_000
filas = []
for p in PERFILES:
    x = lat.loc[lat["perfil"] == p, "rtt_us"] / 1000
    fila = {"perfil": p, "p99": x.quantile(0.99),
            "p99.9": x.quantile(0.999)}
    for t in (100, 200, 500):
        tarde = ((x > t) | x.isna()).mean()  # perdidos cuentan
        fila[f">{t}ms %"] = 100 * tarde
    filas.append(fila)
tab = pd.DataFrame(filas).set_index("perfil")
print(tab.round(2).to_string())
f = tab.loc["retail_espana_nueva_york", ">200ms %"] / 100
print(f"casa-NY, 200 ms: {f * PETICIONES_DIA:.0f} respuestas "
      f"tardías o perdidas al día")
OUT [03]Tiempos de espera y respuestas que llegan tarde
                             p99   p99.9  >100ms %  >200ms %  >500ms %
perfil
retail_espana_nueva_york  126.36  506.39     16.77      0.87      0.50
retail_espana_frankfurt    45.94  184.61      0.47      0.20      0.13
vps_nueva_jersey            2.44    9.64      0.00      0.00      0.00
nube_misma_region           1.24    4.98      0.00      0.00      0.00
casa-NY, 200 ms: 17 respuestas tardías o perdidas al día

Desde casa a Nueva York, con 200 ms de espera, el 0,87 % de los mensajes llega tarde o no llega: con 2.000 peticiones al día (órdenes, cancelaciones, consultas), unas 17 veces al día el bot cree que algo se perdió. Si en cada una reenvía la orden sin clave de idempotencia, duplica la posición (es lo que pasó con R00113 y R00114 en la lección 4.5.5). Subir la espera a 500 ms lo baja al 0,50 %, del que 0,37 puntos son paquetes perdidos de verdad (11 de 3.000), y el coste es reaccionar más tarde a una desconexión real. Con 100 ms, uno de cada seis mensajes llegaría «tarde». Desde un VPS en Nueva Jersey el problema casi desaparece: ningún ping supera los 100 ms ni se pierde. Esa es la mejora que compra un VPS cerca del broker, más que el milisegundo de la mediana: una cola corta.

La regla práctica que sale de aquí: el tiempo de espera se fija con un percentil alto de la latencia medida desde la máquina donde corre el bot, no desde el portátil; se revisa cuando se cambia de proveedor o de región; y el reintento de una orden siempre consulta antes por su clave. Medir es barato: un ping cada pocos segundos a la pasarela del broker, guardado como serie temporal, es una de las métricas de la lección 7.2.3.

El camino del código al servidor

Con la máquina elegida, falta decidir cómo llega cada versión. A mano (entrar por SSH, git pull, reiniciar) funciona hasta el día en que se despliega un commit que no pasaba las pruebas, o con una dependencia distinta de la del portátil. La integración y el despliegue continuos (CI/CD) convierten ese ritual en un proceso que no se salta pasos:

verderojogit pushPruebas y linterImagen con digestSe paraAprobaciónDespliegue
Fuente Mermaid
flowchart TB
  P[git push] --> T[Pruebas y linter]
  T -->|verde| I[Imagen con digest]
  T -->|rojo| X[Se para]
  I --> A[Aprobación]
  A --> D[Despliegue]

GitHub Actions ejecuta ese proceso en máquinas de GitHub cada vez que pasa algo en el repositorio. Según su documentación de facturación, el uso es gratis en repositorios públicos con los runners estándar y, en privados, el plan Free incluye 2.000 minutos al mes2Nota 2 · GitHub Docs, «GitHub Actions billing» (https://docs.github.com/en/billing/concepts/product-billing/github-actions), consultada el 23 de octubre de 2026: uso gratuito en repositorios públicos con runners estándar; 2.000 minutos al mes en GitHub Free y GitHub Free for organizations, 3.000 en Pro y Team.. Un workflow es un fichero YAML en .github/workflows/. El de la lección tiene tres trabajos: pruebas (uv, linter, formato, pytest y una comprobación de los ejemplos del README, el equivalente a check:curso-code de este curso), imagen (construye y publica la imagen de la lección 7.2.1) y despliegue (actualiza el servidor). Las acciones van fijadas por el SHA completo de su commit; los SHA son los de actions/checkout v7.0.1 y astral-sh/setup-uv v10.3.2, consultados con git ls-remote en los repositorios oficiales el 23 de octubre de 2026:

IN [04]python
from pathlib import Path

SHA = {
    "checkout": "3d3c42e5aac5ba805825da76410c181273ba90b1",
    "setup-uv": "07a047e608db84b3d8c06cd9a7c7dcc9c355c18e",
}
WORKFLOW = """\
name: ci
on:
  push:
    branches: [main]
  pull_request:
permissions:
  contents: read
jobs:
  pruebas:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@{checkout}  # v7.0.1
      - uses: astral-sh/setup-uv@{setup-uv}  # v10.3.2
        with:
          version: "0.13.5"
          enable-cache: true
      - name: dependencias
        run: uv sync --locked
      - name: linter
        run: uv run ruff check .
      - name: pruebas
        run: uv run pytest -q
      - name: ejemplos del README
        run: uv run python -m doctest README.md
  imagen:
    needs: pruebas
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@{checkout}  # v7.0.1
      - name: construir y publicar
        run: ./scripts/imagen.sh
  despliegue:
    needs: imagen
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    environment: produccion
    concurrency:
      group: despliegue
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@{checkout}  # v7.0.1
      - name: desplegar por digest
        env:
          SSH_KEY: ${{{{ secrets.DEPLOY_SSH_KEY }}}}
          HOST: ${{{{ vars.DEPLOY_HOST }}}}
        run: ./scripts/desplegar.sh "$HOST"
""".format(**SHA)
Path("ci.yml").write_text(WORKFLOW)
print(len(WORKFLOW.splitlines()), "líneas")
OUT [04]El workflow de CI/CD
53 líneas

Cada pieza tiene un motivo en la documentación de GitHub. permissions: contents: read en el nivel superior: el token del workflow (GITHUB_TOKEN) solo puede leer, y el trabajo imagen amplía lo justo para publicar en el registro de paquetes; en cuanto se declara un permiso, los no declarados pasan a none3Nota 3 · GitHub Docs, «Workflow syntax for GitHub Actions»: permissions, timeout-minutes («Default: 360»), needs y concurrency.. timeout-minutes, porque el valor por defecto es 360 minutos: un test colgado consumiría seis horas de cuota. needs encadena los trabajos y, si uno falla, los que lo necesitan se saltan. environment: produccion permite exigir revisores: GitHub deja configurar hasta seis personas o equipos y basta con que uno apruebe para que el trabajo siga4Nota 4 · GitHub Docs, «Deployments and environments», «Required reviewers» (https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments).. concurrency con cancel-in-progress: false impide dos despliegues a la vez sin cortar uno a medias. Y el secreto entra por env, no dentro del texto de run.

Un validador de workflows

Las reglas de seguridad de GitHub se pueden comprobar antes de hacer push. Hay una trampa al leer el YAML con PyYAML: la clave on del workflow se convierte en el booleano True, porque PyYAML sigue YAML 1.1, donde on, yes y off son booleanos. El validador lo tiene en cuenta:

IN [05]python
import re
import yaml

SHA40 = re.compile(r"@[0-9a-f]{40}$")
EXPR = re.compile(r"\$\{\{\s*([^}]*?)\s*\}\}")
NOMBRE_SECRETO = re.compile(r"^(?!GITHUB_)[A-Za-z_][A-Za-z0-9_]*$")

def validar_workflow(texto):
    w = yaml.safe_load(texto)
    on = w.get("on", w.get(True, {}))      # PyYAML: on -> True
    out = []
    if "pull_request_target" in (on or {}):
        out.append(("-", "GA05", "pull_request_target"))
    if "permissions" not in w:
        out.append(("-", "GA02", "sin permissions en el nivel "
                                 "superior"))
    for nj, job in w["jobs"].items():
        if "timeout-minutes" not in job:
            out.append((nj, "GA03", "sin timeout-minutes (360)"))
        for st in job.get("steps", []):
            uses = st.get("uses", "")
            if uses and not SHA40.search(uses):
                out.append((nj, "GA01", f"acción sin SHA: {uses}"))
            run = st.get("run", "")
            for e in EXPR.findall(run):
                if e.startswith(("secrets.", "github.event.")):
                    out.append((nj, "GA04", f"expresión en run: {e}"))
            for e in EXPR.findall(str(st.get("env", {}))):
                if e.startswith("secrets.") and \
                        not NOMBRE_SECRETO.match(e[8:]):
                    out.append((nj, "GA08", f"nombre de secreto: {e}"))
            if "uv sync" in run and "--locked" not in run \
                    and "--frozen" not in run:
                out.append((nj, "GA07", "uv sync sin --locked"))
        if "desplieg" in nj and not ("environment" in job
                                     and "needs" in job):
            out.append((nj, "GA06", "despliegue sin environment "
                                    "o sin needs"))
    return out

INGENUO = """\
name: ci
on: [push, pull_request_target]
jobs:
  todo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@main
      - run: uv sync && uv run pytest
      - run: echo "${{ github.event.pull_request.title }}"
  despliegue:
    runs-on: ubuntu-latest
    steps:
      - run: ./desplegar.sh ${{ secrets.DEPLOY_SSH_KEY }}
"""
print(yaml.safe_load(INGENUO).keys())
for nombre, w in (("ingenuo", INGENUO), ("bueno", WORKFLOW)):
    h = validar_workflow(w)
    print(f"{nombre}: {len(h)} hallazgos")
    for job, cod, msg in h:
        print(f"  {job:<10} {cod} {msg}")
OUT [05]Validar el workflow: reglas GA01 a GA08
dict_keys(['name', True, 'jobs'])
ingenuo: 10 hallazgos
  -          GA05 pull_request_target
  -          GA02 sin permissions en el nivel superior
  todo       GA03 sin timeout-minutes (360)
  todo       GA01 acción sin SHA: actions/checkout@v4
  todo       GA01 acción sin SHA: astral-sh/setup-uv@main
  todo       GA07 uv sync sin --locked
  todo       GA04 expresión en run: github.event.pull_request.title
  despliegue GA03 sin timeout-minutes (360)
  despliegue GA04 expresión en run: secrets.DEPLOY_SSH_KEY
  despliegue GA06 despliegue sin environment o sin needs
bueno: 0 hallazgos

Diez hallazgos en el ingenuo y ninguno en el bueno. Los más graves son los de las reglas GA04 y GA05. La guía de seguridad de GitHub explica la inyección de scripts: una expresión ${{ … }} dentro de run se sustituye en el texto del script antes de ejecutarlo, así que un título de pull request como a"; curl … # se ejecuta como un comando; la solución es pasar el valor a una variable de entorno intermedia5Nota 5 · GitHub Docs, «Secure use reference», secciones «Use an intermediate environment variable» y «Mitigating the risks of untrusted code checkout».. Con los secretos, el mismo patrón evita además que el valor aparezca en la línea de comandos del proceso. Y pull_request_target corre con permisos del repositorio y acceso a los secretos aunque la pull request venga de un fork: con un checkout de ese código, cualquiera puede ejecutar lo que quiera con las claves de despliegue. La guía pide evitarlo si no es imprescindible.

Sobre GA01 hay una novedad. La guía recomendaba hasta ahora fijar cada acción a un SHA completo; en octubre de 2026 documenta además un lockfile de dependencias de workflows (.github/workflows/actions.lock, generado con la extensión gh actions-lock de la CLI), en vista previa pública, que fija también las dependencias transitivas de las acciones y que GitHub presenta como la protección más fuerte para quien lo adopte6Nota 6 · GitHub Docs, «Workflow dependency lockfiles» (https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles), consultada el 23 de octubre de 2026: «Workflow dependency lockfiles are in public preview and subject to change».. El SHA sigue siendo la opción para quien no lo use.

Un runner local

Para ver el pipeline funcionar sin GitHub, la celda siguiente crea un repositorio de prueba con un bot mínimo (una señal de cruce de medias, su prueba y un README con un ejemplo ejecutable), lo confirma con git y ejecuta el workflow paso a paso. El runner traduce cada run a algo que se puede hacer sin red en el entorno del curso, y lo dice: uv sync --locked se reduce a comprobar que el lockfile corresponde a pyproject.toml (uv lo comprueba resolviendo de nuevo; aquí, con un hash); uv run X se ejecuta con el Python del curso; y como Ruff no está en el entorno principal, el linter se sustituye por una sola de sus reglas, la F401 (importaciones sin usar), escrita con ast. Las acciones uses y los scripts de imagen y despliegue se simulan. Git se configura con un autor de prueba, sin leer la configuración global de quien ejecuta la lección, y con fechas fijas para que los SHA sean siempre los mismos.

IN [06]python
import ast
import hashlib
import os
import subprocess
import sys

REPO = Path("repo-ci").resolve()
GIT_ENV = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull,
           "GIT_CONFIG_NOSYSTEM": "1",
           "GIT_AUTHOR_NAME": "Curso PPT",
           "GIT_AUTHOR_EMAIL": "[email protected]",
           "GIT_COMMITTER_NAME": "Curso PPT",
           "GIT_COMMITTER_EMAIL": "[email protected]"}

def git(*args, fecha="2026-10-23T09:00:00Z"):
    env = {**GIT_ENV, "GIT_AUTHOR_DATE": fecha,
           "GIT_COMMITTER_DATE": fecha}
    r = subprocess.run(["git", *args], cwd=REPO, env=env,
                       capture_output=True, text=True, check=True)
    return r.stdout.strip()

PYPROJECT = '[project]\nname = "bot"\nversion = "0.1.0"\n'
ESTRATEGIA = '''\
def senal(precios, corta=3, larga=5):
    """1 si la media corta supera a la larga; si no, 0.

    >>> senal([1, 2, 3, 4, 5, 6])
    1
    """
    if len(precios) < larga:
        return 0
    m_c = sum(precios[-corta:]) / corta
    m_l = sum(precios[-larga:]) / larga
    return 1 if m_c > m_l else 0
'''
TEST = '''\
from estrategia import senal

def test_sube():
    assert senal([1, 2, 3, 4, 5, 6]) == 1

def test_baja():
    assert senal([6, 5, 4, 3, 2, 1]) == 0

def test_pocos_datos():
    assert senal([1, 2]) == 0
'''
README = """# Bot

    >>> from estrategia import senal
    >>> senal([10, 10, 10, 10, 11])
    1
"""
FICHEROS = {"pyproject.toml": PYPROJECT, "src/estrategia.py": ESTRATEGIA,
            "tests/test_estrategia.py": TEST, "README.md": README,
            ".github/workflows/ci.yml": WORKFLOW}
FICHEROS["uv.lock"] = ("# pyproject-sha256: " + hashlib.sha256(
    PYPROJECT.encode()).hexdigest() + "\n")
REPO.mkdir(exist_ok=True)
for ruta, texto in FICHEROS.items():
    (REPO / ruta).parent.mkdir(parents=True, exist_ok=True)
    (REPO / ruta).write_text(texto)
git("init", "-q", "-b", "main")
git("add", "-A")
git("commit", "-q", "-m", "bot inicial")
print(git("log", "--oneline"))
OUT [06]El repositorio de prueba
b22b953 bot inicial
IN [07]python
from graphlib import TopologicalSorter

def f401(raiz):
    """Importaciones sin usar (regla F401 de Ruff), con ast."""
    malos = []
    for f in sorted(raiz.rglob("*.py")):
        arbol = ast.parse(f.read_text())
        usados = {n.id for n in ast.walk(arbol)
                  if isinstance(n, ast.Name)}
        for n in ast.walk(arbol):
            if isinstance(n, (ast.Import, ast.ImportFrom)):
                for a in n.names:
                    nombre = (a.asname or a.name).split(".")[0]
                    if nombre not in usados:
                        malos.append(f"{f.relative_to(raiz)}:"
                                     f"{n.lineno} F401 {nombre}")
    return malos

def paso(run):
    """(ok, resumen) de un `run`, traducido a algo local."""
    env = {**os.environ, "PYTHONPATH": str(REPO / "src"),
           "PYTHONDONTWRITEBYTECODE": "1"}
    if run.startswith("uv sync --locked"):
        lock = (REPO / "uv.lock").read_text()
        h = hashlib.sha256((REPO / "pyproject.toml")
                           .read_bytes()).hexdigest()
        return h in lock, "lockfile al día" if h in lock else \
            "uv.lock no corresponde a pyproject.toml"
    if run.startswith("uv run ruff check"):
        malos = f401(REPO)
        return not malos, "; ".join(malos) or "sin hallazgos"
    if run.startswith("uv run "):
        cmd = run.split()[2:]
        if cmd[0] == "pytest":
            cmd = ["python", "-m", "pytest", "-p",
                   "no:cacheprovider", *cmd[1:]]
        r = subprocess.run([sys.executable, *cmd[1:]], cwd=REPO,
                           env=env, capture_output=True, text=True)
        ult = (r.stdout.strip().splitlines() or ["ok"])[-1]
        return r.returncode == 0, re.sub(r" in [\d.]+s", "", ult)
    return True, "(simulado)"

def ejecutar(texto, ref="refs/heads/main"):
    w = yaml.safe_load(texto)
    jobs = w["jobs"]
    orden = TopologicalSorter({n: set([j["needs"]] if isinstance(
        j.get("needs"), str) else j.get("needs", []))
        for n, j in jobs.items()}).static_order()
    estado = {}
    for nj in orden:
        job = jobs[nj]
        needs = job.get("needs", [])
        needs = [needs] if isinstance(needs, str) else needs
        cond = job.get("if", "")
        m = re.fullmatch(r"github\.ref == '(.+)'", cond)
        if any(estado[n] != "ok" for n in needs) or (m and
                                                     m[1] != ref):
            estado[nj] = "saltado"
            print(f"[{nj}] saltado")
            continue
        estado[nj] = "ok"
        for st in job["steps"]:
            nombre = st.get("name") or st["uses"].split("@")[0]
            ok, res = (True, "acción simulada") if "uses" in st \
                else paso(st["run"])
            print(f"[{nj}] {nombre}: {'ok' if ok else 'FALLA'}"
                  f" · {res}")
            if not ok:
                estado[nj] = "falla"
                break
    return estado

print(ejecutar((REPO / ".github/workflows/ci.yml").read_text()))
OUT [07]El runner: trabajos, needs y pasos
[pruebas] actions/checkout: ok · acción simulada
[pruebas] astral-sh/setup-uv: ok · acción simulada
[pruebas] dependencias: ok · lockfile al día
[pruebas] linter: ok · sin hallazgos
[pruebas] pruebas: ok · 3 passed
[pruebas] ejemplos del README: ok · ok
[imagen] actions/checkout: ok · acción simulada
[imagen] construir y publicar: ok · (simulado)
[despliegue] actions/checkout: ok · acción simulada
[despliegue] desplegar por digest: ok · (simulado)
{'pruebas': 'ok', 'imagen': 'ok', 'despliegue': 'ok'}

Todo en verde: el lockfile corresponde, el linter no encuentra nada, las tres pruebas y el ejemplo del README pasan, y los trabajos de imagen y despliegue se ejecutan (simulados) porque el commit está en main. Ahora, dos commits rotos. El primero invierte la comparación de las medias, un error de signo de los que pasan desapercibidos en una revisión rápida; el segundo lo corrige pero deja una importación que no se usa:

IN [08]python
def commit(ruta, texto, mensaje, fecha):
    (REPO / ruta).write_text(texto)
    git("add", "-A")
    git("commit", "-q", "-m", mensaje, fecha=fecha)
    return git("rev-parse", "--short", "HEAD")

roto = ESTRATEGIA.replace("m_c > m_l", "m_c < m_l")
print(commit("src/estrategia.py", roto, "estrategia: signo",
             "2026-10-23T10:00:00Z"))
print(ejecutar(WORKFLOW))
arreglado = "import numpy as np\n" + ESTRATEGIA
print(commit("src/estrategia.py", arreglado, "estrategia: arregla",
             "2026-10-23T11:00:00Z"))
print(ejecutar(WORKFLOW))
OUT [08]Dos commits rotos
f304ac3
[pruebas] actions/checkout: ok · acción simulada
[pruebas] astral-sh/setup-uv: ok · acción simulada
[pruebas] dependencias: ok · lockfile al día
[pruebas] linter: ok · sin hallazgos
[pruebas] pruebas: FALLA · 2 failed, 1 passed
[imagen] saltado
[despliegue] saltado
{'pruebas': 'falla', 'imagen': 'saltado', 'despliegue': 'saltado'}
f37293f
[pruebas] actions/checkout: ok · acción simulada
[pruebas] astral-sh/setup-uv: ok · acción simulada
[pruebas] dependencias: ok · lockfile al día
[pruebas] linter: FALLA · src/estrategia.py:1 F401 np
[imagen] saltado
[despliegue] saltado
{'pruebas': 'falla', 'imagen': 'saltado', 'despliegue': 'saltado'}

El error de signo lo cazarían las pruebas y también el ejemplo del README, pero el runner se para en el primer paso que falla, como GitHub: pruebas falla en pytest (dos de tres pruebas), imagen y despliegue se saltan y el servidor sigue con la versión anterior. El segundo commit pasa las pruebas, pero la importación sin usar para el linter antes de llegar a ellas. Un linter estricto parece una molestia hasta que una importación sobrante es la pista de un cambio a medias. En una pull request (ref distinto de main) los trabajos de imagen y despliegue se saltan por su if, y solo corren las pruebas: es el ejercicio 1.

Secretos y despliegue

El trabajo de despliegue necesita una credencial para entrar en el servidor. La lección 4.4.5 trató las credenciales del broker: dónde viven, permisos mínimos y rotación. Las del pipeline siguen las mismas reglas, con tres matices propios de GitHub:

  • Un secreto por propósito y con el mínimo alcance. La clave SSH de despliegue solo sirve para ese servidor y para un usuario que solo puede ejecutar el script de despliegue (command= en authorized_keys). Las claves del broker no van a GitHub: el servidor las tiene y el pipeline nunca las ve.
  • Secretos de entorno. Definidos en el entorno produccion, solo los ve un trabajo que declara ese entorno, y ese trabajo puede exigir la aprobación de un revisor. GitHub tacha los secretos en los registros, y su documentación pide no usar datos estructurados (un JSON entero) como valor de un secreto para que el tachado funcione7Nota 7 · GitHub Docs, «Secrets reference» (https://docs.github.com/en/actions/reference/security/secrets)..
  • Desplegar por digest. El trabajo imagen publica la imagen y anota su digest; el de despliegue le pide al servidor ese digest exacto (docker compose pull con la imagen fijada y up -d --wait), y guarda el anterior para volver atrás. La comprobación de salud de la lección 7.2.1 decide si el despliegue terminó bien.

Para un bot que opera, hay una regla más que ningún workflow trae por defecto: no se despliega con el mercado abierto ni con posiciones a medio gestionar. El trabajo de despliegue puede comprobarlo (una llamada al propio bot que diga si está en un estado seguro para parar, como el arranque conciliado de la lección 4.5.5), y el entorno protegido añade una persona que lo mira.

Los comandos reales, que en el entorno del curso no se ejecutan, serían estos:

IN [09]python
for args in (["git", "ls-remote", "https://github.com/actions/checkout",
              "refs/tags/v7.0.1"],
             ["uv", "lock", "--check"],
             ["uvx", "ruff", "check", "."],
             ["gh", "workflow", "run", "ci", "--ref", "main"]):
    subprocess.run(args, cwd=REPO, check=True)

Ejercicios

  1. Ejecuta el workflow sobre el último commit como si viniera de una pull request (ref="refs/pull/7/merge"), primero con la importación sobrante y después sin ella. ¿Qué trabajos corren?
Solución
IN [10]python
print(ejecutar(WORKFLOW, ref="refs/pull/7/merge"))
commit("src/estrategia.py", ESTRATEGIA, "quita la importación",
       "2026-10-23T12:00:00Z")
print(ejecutar(WORKFLOW, ref="refs/pull/7/merge"))
OUT [10]El mismo workflow en una pull request
[pruebas] actions/checkout: ok · acción simulada
[pruebas] astral-sh/setup-uv: ok · acción simulada
[pruebas] dependencias: ok · lockfile al día
[pruebas] linter: FALLA · src/estrategia.py:1 F401 np
[imagen] saltado
[despliegue] saltado
{'pruebas': 'falla', 'imagen': 'saltado', 'despliegue': 'saltado'}
[pruebas] actions/checkout: ok · acción simulada
[pruebas] astral-sh/setup-uv: ok · acción simulada
[pruebas] dependencias: ok · lockfile al día
[pruebas] linter: ok · sin hallazgos
[pruebas] pruebas: ok · 3 passed
[pruebas] ejemplos del README: ok · ok
[imagen] saltado
[despliegue] saltado
{'pruebas': 'ok', 'imagen': 'saltado', 'despliegue': 'saltado'}

En una pull request solo corren las pruebas: los dos trabajos siguientes tienen if: github.ref == 'refs/heads/main'. Primero fallan por el linter; quitada la importación, pasan, y la pull request queda en verde lista para revisar. El despliegue ocurrirá cuando se fusione en main, no antes.

  1. Con latencia4_rtt.parquet, calcula para cada perfil el tiempo de espera mínimo (en ms, redondeado al alza a la decena) que deja como mucho el 0,1 % de pings por encima, sin contar los perdidos. ¿Por qué no tiene sentido usar el mismo valor para todos?
Solución
IN [11]python
import numpy as np

for p in PERFILES:
    x = lat.loc[(lat["perfil"] == p) & ~lat["perdido"],
                "rtt_us"] / 1000
    t = np.ceil(x.quantile(0.999) / 10) * 10
    print(f"{p:<26} {t:>6.0f} ms  ({(x > t).mean():.3%} por "
          f"encima)")
OUT [11]Un tiempo de espera por perfil
retail_espana_nueva_york      510 ms  (0.100% por encima)
retail_espana_frankfurt       190 ms  (0.100% por encima)
vps_nueva_jersey               10 ms  (0.100% por encima)
nube_misma_region              10 ms  (0.100% por encima)

Los valores van de 10 ms en el VPS y la nube a 510 ms desde casa a Nueva York. Un tiempo de espera común sería o demasiado corto para la conexión doméstica (falsos timeouts y reenvíos) o demasiado largo para el VPS (el bot tardaría en darse cuenta de una caída real). Por eso el tiempo de espera es configuración de cada despliegue, medida desde su máquina, y no una constante del código.

  1. Un compañero propone desplegar automáticamente cada push a main en el servidor donde el bot opera con dinero real, «porque las pruebas ya pasan». ¿Qué añadirías antes de aceptarlo?
Solución

Que pasar las pruebas es condición necesaria, no suficiente. Añadiría: un entorno protegido con un revisor que apruebe el despliegue; una ventana de despliegue fuera del horario de mercado, comprobada por el propio trabajo; que el bot confirme que está en un estado seguro (sin órdenes vivas pendientes de confirmar, posiciones conciliadas) antes de pararlo; despliegue por digest, con el digest anterior guardado para volver atrás en un comando; y paper trading de la misma imagen durante un periodo antes de pasarla a la cuenta real (lección 4.5.3). Y, si el cambio afecta a la estrategia y no solo al código, la validación de la lección 7.3.2.

Footnotes

  1. ICE, «ICE Global Network & Colocation» (https://www.ice.com/fixed-income-data-services/access-and-delivery/global-network), consultada el 23 de octubre de 2026: «ICE Colocation provides a highly deterministic path with the lowest latency to ICE Futures (Basildon, Chicago), as well as National Market System (NMS) and NYSE (Mahwah)». ↩

  2. GitHub Docs, «GitHub Actions billing» (https://docs.github.com/en/billing/concepts/product-billing/github-actions), consultada el 23 de octubre de 2026: uso gratuito en repositorios públicos con runners estándar; 2.000 minutos al mes en GitHub Free y GitHub Free for organizations, 3.000 en Pro y Team. ↩

  3. GitHub Docs, «Workflow syntax for GitHub Actions»: permissions, timeout-minutes («Default: 360»), needs y concurrency. ↩

  4. GitHub Docs, «Deployments and environments», «Required reviewers» (https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). ↩

  5. GitHub Docs, «Secure use reference», secciones «Use an intermediate environment variable» y «Mitigating the risks of untrusted code checkout». ↩

  6. GitHub Docs, «Workflow dependency lockfiles» (https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles), consultada el 23 de octubre de 2026: «Workflow dependency lockfiles are in public preview and subject to change». ↩

  7. GitHub Docs, «Secrets reference» (https://docs.github.com/en/actions/reference/security/secrets). ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un bot en un VPS de Madrid opera en una bolsa cuyo centro de datos está en Mahwah (Nueva Jersey). ¿Cuál es el mínimo físico de ida y vuelta por fibra?

    • AUnos 56 ms: 5.761 km de ortodrómica, ida y vuelta, a la velocidad de la luz en el vidrio (c dividida por 1,47); por cable real, más
    • BUnos 6 ms, lo que tarda un paquete en salir de la tarjeta de red
    • CUnos 39 ms, lo que tarda la luz en el vacío
    • DDepende solo del proveedor de Internet
    Ver respuesta

    Respuesta A. Unos 56 ms: 5.761 km de ortodrómica, ida y vuelta, a la velocidad de la luz en el vidrio (c dividida por 1,47); por cable real, más

    La distancia ortodrómica Madrid-Mahwah es de unos 5.761 km. A 299.792 km/s dividido por 1,47, la ida y vuelta mínima es de 56,5 ms; 38,4 ms sería en el vacío, que ningún cable alcanza. Los cables reales no siguen la ortodrómica, así que la latencia real es mayor.

  2. El perfil de latencia de casa a Nueva York tiene una mediana de 88,5 ms. El bot espera 200 ms la respuesta de cada orden antes de darla por perdida. ¿Qué pasa?

    • ANunca hay falsos timeouts: 200 ms es más del doble de la mediana
    • BHay timeouts constantes
    • CEl 0,87 % de los mensajes supera los 200 ms o se pierde; con 2.000 peticiones al día son unas 17 respuestas tardías que el bot cree perdidas, y cada una es una orden que podría reenviar
    • DLos timeouts solo importan en alta frecuencia
    Ver respuesta

    Respuesta C. El 0,87 % de los mensajes supera los 200 ms o se pierde; con 2.000 peticiones al día son unas 17 respuestas tardías que el bot cree perdidas, y cada una es una orden que podría reenviar

    La mediana no dice nada de la cola. En los datos, el 0,87 % de los pings de casa a Nueva York tarda más de 200 ms o no llega. Una respuesta que llega tarde no es una orden perdida: por eso hace falta la clave de idempotencia de la lección 4.5.5 antes de reenviar.

  3. ¿Qué recomienda la documentación de seguridad de GitHub para usar una acción de terceros en un workflow, si el repositorio no usa el lockfile de dependencias de workflows?

    • AUsar la etiqueta @v7, que siempre apunta a la última versión segura
    • BCopiar la acción dentro del repositorio
    • CUsar @main para tener las correcciones al día
    • DFijarla a un SHA completo de commit, comprobando que es del repositorio de la acción y no de un fork: una etiqueta se puede mover o borrar si alguien toma el repositorio
    Ver respuesta

    Respuesta D. Fijarla a un SHA completo de commit, comprobando que es del repositorio de la acción y no de un fork: una etiqueta se puede mover o borrar si alguien toma el repositorio

    GitHub recomienda el SHA completo porque mover una etiqueta es trivial para quien controle el repositorio, y falsificar un commit exigiría una colisión de SHA-1. Desde 2026 hay además un lockfile de dependencias de workflows (.github/workflows/actions.lock), en vista previa pública el 23 de octubre de 2026, que GitHub presenta como la protección más fuerte.

  4. En un paso run: aparece echo ${{ github.event.pull_request.title }}. ¿Cuál es el problema?

    • ANinguno: GitHub escapa siempre las expresiones
    • BLa expresión se sustituye en el texto del script antes de ejecutarlo: un título como a"; curl … se ejecuta como comando. Hay que pasar el valor por una variable de entorno intermedia
    • CSolo que el título puede estar vacío
    • DQue el contexto github no existe en los pasos run
    Ver respuesta

    Respuesta B. La expresión se sustituye en el texto del script antes de ejecutarlo: un título como a"; curl … se ejecuta como comando. Hay que pasar el valor por una variable de entorno intermedia

    Es la inyección de scripts que describe la guía de seguridad de GitHub. Con env: TITLE: ${{ … }} y "$TITLE" en el script, el valor se queda en memoria como variable y no se mezcla con el código. Lo mismo vale para los secretos: a env, nunca dentro del texto del comando.

  5. En el runner local, el commit que invierte el signo de la señal hace fallar pytest. ¿Qué hace el pipeline con los trabajos siguientes?

    • ALos ejecuta igualmente, en paralelo
    • BEjecuta la construcción pero no el despliegue
    • CLos salta: un trabajo con needs no corre si el que necesita falla, y el servidor sigue con la versión anterior
    • DDespliega la versión anterior automáticamente
    Ver respuesta

    Respuesta C. Los salta: un trabajo con needs no corre si el que necesita falla, y el servidor sigue con la versión anterior

    Según la referencia de sintaxis de workflows, si un trabajo falla o se salta, los que lo necesitan se saltan salvo que usen una condición como always(). El despliegue solo ocurre con todas las comprobaciones en verde, y el bot en producción no se entera del commit roto.

Ideas clave

  • El horizonte manda: una estrategia diaria puede vivir en un VPS cualquiera; una intradía de minutos, en la región del broker; por debajo del milisegundo hace falta colocación, que es otro negocio.
  • La luz en la fibra hace unos 204 km por milisegundo. Madrid-Mahwah son 5.761 km de ortodrómica: 56,5 ms de ida y vuelta como mínimo; Ashburn-Mahwah, 3,6 ms; dentro del mismo centro de datos, microsegundos.
  • La cola importa más que la mediana: de casa a Nueva York, el 0,87 % de los mensajes tarda más de 200 ms o se pierde, y desde un VPS en Nueva Jersey ninguno. Los tiempos de espera se fijan con percentiles altos y las órdenes se reenvían solo con idempotencia.
  • Un workflow seguro: acciones fijadas por SHA completo (o el lockfile de workflows), permissions mínimos, timeout-minutes, secretos solo en env, nada de pull_request_target con código ajeno y uv sync --locked.
  • El despliegue depende de las pruebas (needs) y va a un entorno protegido con revisores; un commit que rompe una prueba o el linter no llega al servidor.
  • Los repositorios públicos usan los runners estándar de GitHub gratis; en privados, el plan Free incluye 2.000 minutos al mes (documentación de GitHub, 23 de octubre de 2026).

Recursos