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.
- Módulo
- 7.2 · Infraestructura y despliegue
- Duración
- 70 min
- Requisitos
- 0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore, 4.3.2 · Order flow imbalance, señales de microestructura y latencia, 4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos, 4.5.5 · Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar, 7.2.1 · Docker y docker-compose: imágenes reproducibles con uv
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
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:
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 y longitudes :
con 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.
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}")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.5La 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.
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}")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.19La 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:
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") 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íaDesde 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:
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:
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")53 líneasCada 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:
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}")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 hallazgosDiez 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.
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"))b22b953 bot inicialfrom 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()))[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:
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))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=enauthorized_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
imagenpublica la imagen y anota su digest; el de despliegue le pide al servidor ese digest exacto (docker compose pullcon la imagen fijada yup -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:
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
- 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
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"))[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.
- 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
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)")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.
- Un compañero propone desplegar automáticamente cada push a
mainen 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
-
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)». ↩
-
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. ↩
-
GitHub Docs, «Workflow syntax for GitHub Actions»:
permissions,timeout-minutes(«Default: 360»),needsyconcurrency. ↩ -
GitHub Docs, «Deployments and environments», «Required reviewers» (https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments). ↩
-
GitHub Docs, «Secure use reference», secciones «Use an intermediate environment variable» y «Mitigating the risks of untrusted code checkout». ↩
-
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». ↩
-
GitHub Docs, «Secrets reference» (https://docs.github.com/en/actions/reference/security/secrets). ↩
Respuesta plegada en cada pregunta
Autoevaluación
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.
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.
¿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
@mainpara 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.- AUsar la etiqueta
En un paso
run:apareceecho ${{ 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
githubno existe en los pasosrun
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 intermediaEs 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: aenv, nunca dentro del texto del comando.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
needsno 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
needsno corre si el que necesita falla, y el servidor sigue con la versión anteriorSegú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),
permissionsmínimos,timeout-minutes, secretos solo enenv, nada depull_request_targetcon código ajeno yuv 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).