---
title: "7.2.2 VPS, nube, latencia y CI/CD"
description: "VPS o nube para un bot, el suelo físico de la latencia entre ciudades reales, colas medidas y un CI/CD con GitHub Actions validado y ejecutado en local."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/vps-nube-latencia-y-ci-cd/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/vps-nube-latencia-y-ci-cd.png"
---

# 7.2.2 · 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.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.2 · Infraestructura y despliegue
- Duración: 70 min
- Requisitos: [0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/), [4.3.2 · Order flow imbalance, señales de microestructura y latencia](https://pythonparatrading.com/curso-python-trading/nivel-4/order-flow-imbalance-y-latencia/), [4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos](https://pythonparatrading.com/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos/), [4.5.5 · Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar](https://pythonparatrading.com/curso-python-trading/nivel-4/tolerancia-a-fallos-idempotencia-y-reconciliacion/), [7.2.1 · Docker y docker-compose: imágenes reproducibles con uv](https://pythonparatrading.com/curso-python-trading/nivel-7/docker-y-docker-compose-con-uv/)

## Al terminar esta lección

- Elegir entre casa, VPS, nube y colocación según el horizonte de la estrategia, y ubicar las regiones de los proveedores respecto a los mercados.
- Calcular el suelo físico de la latencia entre dos ciudades con la distancia ortodrómica, la velocidad de la luz en el vacío y en la fibra.
- Medir la cola de una distribución de latencias y traducirla en tiempos de espera, falsos timeouts y riesgo de órdenes duplicadas.
- Escribir un workflow de GitHub Actions con uv, pruebas, linter y despliegue, y validarlo contra las reglas de seguridad de GitHub: acciones fijadas por SHA, permisos mínimos, secretos fuera de los scripts.
- Ejecutar un pipeline de CI en local sobre un repositorio de prueba y ver cómo un fallo detiene el despliegue.

## 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ónde | Qué se gana | Qué se pierde | Para qué horizonte |
|---|---|---|---|
| Casa | coste cero, control total | luz, router, IP dinámica, cortes | research, paper, diaria con supervisión |
| VPS | máquina siempre encendida, IP fija, precio fijo al mes | recursos compartidos con otros clientes | diaria e intradía de minutos |
| Nube (región del broker) | regiones junto a las pasarelas, servicios gestionados | facturación por uso, más piezas | intradía de segundos |
| Colocación | microsegundos al motor de casamiento | contratos con el mercado, hardware propio | otro 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:

```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 = 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 \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)[^1]; 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.

```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}")
```
```output
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.

```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}")
```
```output
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:

```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")
```
```output
                             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:

```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 mes[^2]. 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:

```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")
```
```output
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 `none`[^3]. `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 siga[^4]. `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:

```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}")
```
```output
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 intermedia[^5]. 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 adopte[^6]. 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.

```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": "curso@example.com",
           "GIT_COMMITTER_NAME": "Curso PPT",
           "GIT_COMMITTER_EMAIL": "curso@example.com"}

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"))
```
```output
b22b953 bot inicial
```

```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()))
```
```output
[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:

```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))
```
```output
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 funcione[^7].
- **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:

```python skip
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.**

```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"))
```
```output
[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.

2. 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.**

```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)")
```
```output
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.

3. 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.

[^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).

## 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?

   - 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
   - B. Unos 6 ms, lo que tarda un paquete en salir de la tarjeta de red
   - C. Unos 39 ms, lo que tarda la luz en el vacío
   - D. Depende solo del proveedor de Internet

   *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?

   - A. Nunca hay falsos timeouts: 200 ms es más del doble de la mediana
   - B. Hay timeouts constantes
   - 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
   - D. Los timeouts solo importan en alta frecuencia

   *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?

   - A. Usar la etiqueta `@v7`, que siempre apunta a la última versión segura
   - B. Copiar la acción dentro del repositorio
   - C. Usar `@main` para tener las correcciones al día
   - 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

   *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?

   - A. Ninguno: GitHub escapa siempre las expresiones
   - 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
   - C. Solo que el título puede estar vacío
   - D. Que el contexto `github` no existe en los pasos `run`

   *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?

   - A. Los ejecuta igualmente, en paralelo
   - B. Ejecuta la construcción pero no el despliegue
   - C. Los salta: un trabajo con `needs` no corre si el que necesita falla, y el servidor sigue con la versión anterior
   - D. Despliega la versión anterior automáticamente

   *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

### Documentación

- [GitHub Docs: Secure use reference (Actions)](https://docs.github.com/en/actions/reference/security/secure-use) · docs.github.com
- [GitHub Docs: Workflow syntax for GitHub Actions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) · docs.github.com
- [GitHub Docs: Workflow dependency lockfiles](https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles) · docs.github.com
- [uv: Using uv in GitHub Actions](https://docs.astral.sh/uv/guides/integration/github/) · docs.astral.sh
- [AWS: Regions and Availability Zones (lista de regiones)](https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html) · docs.aws.amazon.com
- [Hetzner Docs: Cloud locations](https://docs.hetzner.com/cloud/general/locations/) · docs.hetzner.com

### Datos

- [Datos del nivel 4: latencias de seis perfiles de conexión (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/latencia4_rtt.parquet) · Descarga · latencia4_rtt.parquet

Lección anterior: [7.2.1 · Docker y docker-compose: imágenes reproducibles con uv](https://pythonparatrading.com/curso-python-trading/nivel-7/docker-y-docker-compose-con-uv/)

Lección siguiente: [7.2.3 · Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos](https://pythonparatrading.com/curso-python-trading/nivel-7/orquestacion-y-monitorizacion/)
