Lesson 7.2.2 · 70 min

VPS, cloud and latency; CI/CD with GitHub Actions

The distance between the bot and the market has a floor that no provider can lower: light in fiber travels about 200 kilometers per millisecond. This lesson computes that floor for pairs of real cities, compares it with measured latencies and decides with numbers when getting closer is needed. Then it builds the path from code to server with GitHub Actions: a workflow validated with security rules from the official documentation and a local runner that executes it on a test repository.

Fig. — Physical floor of round-trip latency between city pairs with markets or cloud regions: light in fiber (c/1.47) along the great circle, bar, and in vacuum, tick
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control

Where a bot runs

Lesson 7.2.1 left the bot in an image that runs the same on any machine with Docker. This one decides which machine, and how each version reaches it. There are four options, and the question that ranks them is not price but the strategy's horizon: how much a millisecond is worth to it.

WhereWhat you gainWhat you loseFor which horizon
Homezero cost, full controlpower, router, dynamic IP, outagesresearch, paper, daily with supervision
VPSmachine always on, fixed IP, fixed monthly priceresources shared with other customersdaily and intraday on minutes
Cloud (broker's region)regions next to the gateways, managed servicespay-per-use billing, more moving partsintraday on seconds
Colocationmicroseconds to the matching enginecontracts with the market, own hardwareanother business

A strategy that rebalances at the close gains nothing from 50 ms less: the price of the closing order is the same for everyone. One that reacts to a change in the order book within seconds does, and lesson 4.3.2 measured how much is lost with each connection profile. What almost all of them need is something else: that the machine stays on, with a fixed IP (the IP allowlists of brokers and exchanges require it, lesson 4.4.5), and without depending on the home fiber line. That is why the VPS is the default option and the cloud is chosen by region, not by brand:

yesnoyesnoHorizonDays or hoursAny VPSSecondsBroker's regionColocation
Mermaid source
flowchart TB
  A[Horizon] --> B{Days or hours}
  B -->|yes| V[Any VPS]
  B -->|no| C{Seconds}
  C -->|yes| R[Broker's region]
  C -->|no| K[Colocation]

Providers publish where their regions are. Checked in their documentation on October 23, 2026: AWS has us-east-1 (Northern Virginia), eu-central-1 (Frankfurt), eu-west-2 (London) and eu-south-2 (Spain, which has to be enabled explicitly); Google Cloud, us-east4 (Ashburn, Virginia), europe-west3 (Frankfurt) and europe-southwest1 (Madrid); Azure, Spain Central (Madrid); Hetzner Cloud, Falkenstein and Nuremberg (Germany), Helsinki, Ashburn, Hillsboro (Oregon) and Singapore. AWS, Google Cloud and Azure prices depend on the region, the machine type and the outbound traffic, and each has its official calculator; that day Hetzner Cloud's page advertised plans from €5.99 a month excluding VAT (shared, “Cost-Optimized”), €6.49 (“Regular Performance”) and €43.49 with dedicated vCPUs (“General Purpose”). There is no provider recommendation here: there is a list of questions (region, fixed IP, price of outbound data, backups, support) that lesson 7.2.4 completes with business continuity.

The latency floor

No commercial offer changes physics. Light in a vacuum travels at 299,792.458 km/s; in the glass of a fiber, at that speed divided by the refractive index, around 1.47 (the value lesson 4.3.2 used): about 204,000 km/s, two thirds of the speed in a vacuum. The minimum distance between two points on Earth is the great-circle distance, the arc of a great circle, computed with the haversine formula from latitudes φ\varphi and 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}}

with R≈6371R \approx 6371 km. The places in the cell are cities with markets or cloud regions: according to ICE, its colocation centers give access to ICE Futures in Basildon (United Kingdom) and Chicago, and to the NYSE in Mahwah (New Jersey)1Note 1 · ICE, “ICE Global Network & Colocation” (https://www.ice.com/fixed-income-data-services/access-and-delivery/global-network), accessed on October 23, 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)”.; Frankfurt and Ashburn are AWS, Google Cloud and Hetzner regions. The coordinates are the approximate centers of each town: a difference of a few kilometers does not change the result.

IN [01]python
import math

C = 299_792.458                    # km/s in a vacuum
N_FIBRA = 1.47
LUGAR = {
    "Madrid": (40.4168, -3.7038),
    "Frankfurt": (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   # round trip

PARES = [("Ashburn", "Mahwah"), ("Chicago", "Mahwah"),
         ("Frankfurt", "Basildon"), ("Madrid", "Frankfurt"),
         ("Madrid", "Basildon"), ("Basildon", "Mahwah"),
         ("Madrid", "Mahwah")]
SUELO = {}
print(f"{'pair':<20}{'km':>7}{'vacuum ms':>10}{'fiber 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]Great-circle distance and round-trip floor
pair                     km vacuum ms  fiber ms
Ashburn-Mahwah          364       2.4       3.6
Chicago-Mahwah         1126       7.5      11.0
Frankfurt-Basildon      598       4.0       5.9
Madrid-Frankfurt       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

The figure at the top sorts the pairs from shortest to longest. Three things can be read in it. A bot in Madrid that trades in New York starts with 56.5 ms lost before doing anything; one in Ashburn (Virginia), with 3.6. Between the two shores of the Atlantic there are almost 55 ms of fiber at a minimum: no provider can offer “low transatlantic latency” below that. And the vacuum column is not decoration: radio links travel through the air, almost at the speed of light in a vacuum, and on routes such as Chicago-New Jersey the difference between 7.5 and 11.0 ms is what separates whoever gets there first. For an individual, the practical conclusion is more modest: the floor says which improvement is impossible and when moving is worth it.

What is measured on top of the floor

A real latency is the floor plus everything else: the cable does not follow the great circle, every router adds queues, the operating system and the process itself add their share. Lesson 4.3.2 described the six connection profiles in latencia4_rtt.parquet (synthetic course data, one ping every 2 seconds for 100 minutes). Here the interest is different: how far each profile is from its floor and what its tail does to a timeout.

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", "Frankfurt"))):
    s = SUELO[par]
    print(f"{perfil:<26} median {med[perfil]:5.1f} ms  "
          f"floor {s:4.1f} ms  x{med[perfil] / s:.2f}")
OUT [02]Route inflation: measured median versus the floor
retail_espana_nueva_york   median  88.5 ms  floor 56.5 ms  x1.57
retail_espana_frankfurt    median  31.1 ms  floor 14.2 ms  x2.19

The home connection to New York takes 1.57 times its floor, and the one to Frankfurt 2.19. That is the usual pattern: the fixed part (routes that do not go in a straight line, hops between carriers, the last mile) weighs more the shorter the path. For the timeout, however, the median is almost irrelevant. A bot that sends an order and waits for the confirmation has to decide how long to wait before considering it lost, and what counts is the tail:

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()  # lost ones count
        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"home-NY, 200 ms: {f * PETICIONES_DIA:.0f} late or lost "
      f"responses per day")
OUT [03]Timeouts and responses that arrive late
                             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
home-NY, 200 ms: 17 late or lost responses per day

From home to New York, with a 200 ms timeout, 0.87% of the messages arrive late or never arrive: with 2,000 requests a day (orders, cancellations, queries), about 17 times a day the bot believes something was lost. If each time it resends the order without an idempotency key, it duplicates the position (that is what happened with R00113 and R00114 in lesson 4.5.5). Raising the timeout to 500 ms brings it down to 0.50%, of which 0.37 points are packets really lost (11 out of 3,000), and the cost is reacting later to a real disconnection. With 100 ms, one message in six would arrive “late”. From a VPS in New Jersey the problem almost disappears: no ping exceeds 100 ms or is lost. That is the improvement a VPS near the broker buys, more than the millisecond of the median: a short tail.

The practical rule that follows: the timeout is set with a high percentile of the latency measured from the machine where the bot runs, not from the laptop; it is reviewed when changing provider or region; and retrying an order always checks its key first. Measuring is cheap: a ping every few seconds to the broker's gateway, stored as a time series, is one of the metrics of lesson 7.2.3.

The path from code to server

With the machine chosen, it remains to decide how each version gets there. By hand (log in over SSH, git pull, restart) works until the day you deploy a commit that did not pass the tests, or with a dependency different from the laptop's. Continuous integration and deployment (CI/CD) turns that ritual into a process that does not skip steps:

greenredgit pushTests and linterImage with digestStopsApprovalDeployment
Mermaid source
flowchart TB
  P[git push] --> T[Tests and linter]
  T -->|green| I[Image with digest]
  T -->|red| X[Stops]
  I --> A[Approval]
  A --> D[Deployment]

GitHub Actions runs that process on GitHub's machines every time something happens in the repository. According to its billing documentation, usage is free in public repositories with the standard runners and, in private ones, the Free plan includes 2,000 minutes a month2Note 2 · GitHub Docs, “GitHub Actions billing” (https://docs.github.com/en/billing/concepts/product-billing/github-actions), accessed on October 23, 2026: free usage in public repositories with standard runners; 2,000 minutes a month on GitHub Free and GitHub Free for organizations, 3,000 on Pro and Team.. A workflow is a YAML file in .github/workflows/. The lesson's one has three jobs: pruebas (uv, linter, formatting, pytest and a check of the README examples, the equivalent of this course's check:curso-code), imagen (builds and publishes the image from lesson 7.2.1) and despliegue (updates the server). The actions are pinned by the full SHA of their commit; the SHAs are those of actions/checkout v7.0.1 and astral-sh/setup-uv v10.3.2, looked up with git ls-remote in the official repositories on October 23, 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: dependencies
        run: uv sync --locked
      - name: linter
        run: uv run ruff check .
      - name: tests
        run: uv run pytest -q
      - name: README examples
        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: build and push
        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: deploy by 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()), "lines")
OUT [04]The CI/CD workflow
53 lines

Each piece has a reason in GitHub's documentation. permissions: contents: read at the top level: the workflow token (GITHUB_TOKEN) can only read, and the imagen job widens just enough to publish to the package registry; as soon as one permission is declared, the undeclared ones become none3Note 3 · GitHub Docs, “Workflow syntax for GitHub Actions”: permissions, timeout-minutes (“Default: 360”), needs and concurrency.. timeout-minutes, because the default is 360 minutes: a hung test would consume six hours of quota. needs chains the jobs and, if one fails, the ones that need it are skipped. environment: produccion makes it possible to require reviewers: GitHub lets you configure up to six people or teams, and one approval is enough for the job to proceed4Note 4 · GitHub Docs, “Deployments and environments”, “Required reviewers” (https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments).. concurrency with cancel-in-progress: false prevents two deployments at once without cutting one off halfway. And the secret comes in through env, not inside the run text.

A workflow validator

GitHub's security rules can be checked before pushing. There is a trap when reading the YAML with PyYAML: the workflow's on key becomes the boolean True, because PyYAML follows YAML 1.1, where on, yes and off are booleans. The validator takes it into account:

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", "no top-level permissions"))
    for nj, job in w["jobs"].items():
        if "timeout-minutes" not in job:
            out.append((nj, "GA03", "no 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"action without SHA: {uses}"))
            run = st.get("run", "")
            for e in EXPR.findall(run):
                if e.startswith(("secrets.", "github.event.")):
                    out.append((nj, "GA04", f"expression in 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"secret name: {e}"))
            if "uv sync" in run and "--locked" not in run \
                    and "--frozen" not in run:
                out.append((nj, "GA07", "uv sync without --locked"))
        if "desplieg" in nj and not ("environment" in job
                                     and "needs" in job):
            out.append((nj, "GA06", "deployment without environment "
                                    "or 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 (("naive", INGENUO), ("good", WORKFLOW)):
    h = validar_workflow(w)
    print(f"{nombre}: {len(h)} findings")
    for job, cod, msg in h:
        print(f"  {job:<10} {cod} {msg}")
OUT [05]Validating the workflow: rules GA01 to GA08
dict_keys(['name', True, 'jobs'])
naive: 10 findings
  -          GA05 pull_request_target
  -          GA02 no top-level permissions
  todo       GA03 no timeout-minutes (360)
  todo       GA01 action without SHA: actions/checkout@v4
  todo       GA01 action without SHA: astral-sh/setup-uv@main
  todo       GA07 uv sync without --locked
  todo       GA04 expression in run: github.event.pull_request.title
  despliegue GA03 no timeout-minutes (360)
  despliegue GA04 expression in run: secrets.DEPLOY_SSH_KEY
  despliegue GA06 deployment without environment or needs
good: 0 findings

Ten findings in the naive one and none in the good one. The most serious are those of rules GA04 and GA05. GitHub's security guide explains script injection: an expression ${{ … }} inside run is substituted into the script text before it runs, so a pull request title such as a"; curl … # runs as a command; the solution is to pass the value through an intermediate environment variable5Note 5 · GitHub Docs, “Secure use reference”, sections “Use an intermediate environment variable” and “Mitigating the risks of untrusted code checkout”.. With secrets, the same pattern also keeps the value from appearing in the process command line. And pull_request_target runs with repository permissions and access to secrets even when the pull request comes from a fork: with a checkout of that code, anyone can run whatever they want with the deployment keys. The guide asks you to avoid it unless it is essential.

On GA01 there is something new. Until now the guide recommended pinning each action to a full SHA; in October 2026 it also documents a workflow dependency lockfile (.github/workflows/actions.lock, generated with the CLI's gh actions-lock extension), in public preview, which also pins the actions' transitive dependencies and which GitHub presents as the strongest protection for those who adopt it6Note 6 · GitHub Docs, “Workflow dependency lockfiles” (https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles), accessed on October 23, 2026: “Workflow dependency lockfiles are in public preview and subject to change”.. The SHA remains the option for those who do not use it.

A local runner

To see the pipeline work without GitHub, the next cell creates a test repository with a minimal bot (a moving average crossover signal, its test and a README with a runnable example), commits it with git and runs the workflow step by step. The runner translates each run into something that can be done without network access in the course environment, and says so: uv sync --locked is reduced to checking that the lockfile matches pyproject.toml (uv checks it by resolving again; here, with a hash); uv run X runs with the course's Python; and since Ruff is not in the main environment, the linter is replaced by a single one of its rules, F401 (unused imports), written with ast. The uses actions and the image and deployment scripts are simulated. Git is configured with a test author, without reading the global configuration of whoever runs the lesson, and with fixed dates so that the SHAs are always the same.

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 if the short average is above the long one; otherwise 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", "initial bot")
print(git("log", "--oneline"))
OUT [06]The test repository
f9a7fbc initial bot
IN [07]python
from graphlib import TopologicalSorter

def f401(raiz):
    """Unused imports (Ruff rule F401), with 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, summary) of a `run`, translated into something 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 up to date" if h in lock else \
            "uv.lock does not match pyproject.toml"
    if run.startswith("uv run ruff check"):
        malos = f401(REPO)
        return not malos, "; ".join(malos) or "no findings"
    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, "(simulated)"

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] = "skipped"
            print(f"[{nj}] skipped")
            continue
        estado[nj] = "ok"
        for st in job["steps"]:
            nombre = st.get("name") or st["uses"].split("@")[0]
            ok, res = (True, "simulated action") if "uses" in st \
                else paso(st["run"])
            print(f"[{nj}] {nombre}: {'ok' if ok else 'FAIL'}"
                  f" · {res}")
            if not ok:
                estado[nj] = "failed"
                break
    return estado

print(ejecutar((REPO / ".github/workflows/ci.yml").read_text()))
OUT [07]The runner: jobs, needs and steps
[pruebas] actions/checkout: ok · simulated action
[pruebas] astral-sh/setup-uv: ok · simulated action
[pruebas] dependencies: ok · lockfile up to date
[pruebas] linter: ok · no findings
[pruebas] tests: ok · 3 passed
[pruebas] README examples: ok · ok
[imagen] actions/checkout: ok · simulated action
[imagen] build and push: ok · (simulated)
[despliegue] actions/checkout: ok · simulated action
[despliegue] deploy by digest: ok · (simulated)
{'pruebas': 'ok', 'imagen': 'ok', 'despliegue': 'ok'}

All green: the lockfile matches, the linter finds nothing, the three tests and the README example pass, and the image and deployment jobs run (simulated) because the commit is on main. Now, two broken commits. The first flips the comparison of the averages, a sign error of the kind that goes unnoticed in a quick review; the second fixes it but leaves an unused import:

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, "strategy: sign",
             "2026-10-23T10:00:00Z"))
print(ejecutar(WORKFLOW))
arreglado = "import numpy as np\n" + ESTRATEGIA
print(commit("src/estrategia.py", arreglado, "strategy: fix",
             "2026-10-23T11:00:00Z"))
print(ejecutar(WORKFLOW))
OUT [08]Two broken commits
54d54bd
[pruebas] actions/checkout: ok · simulated action
[pruebas] astral-sh/setup-uv: ok · simulated action
[pruebas] dependencies: ok · lockfile up to date
[pruebas] linter: ok · no findings
[pruebas] tests: FAIL · 2 failed, 1 passed
[imagen] skipped
[despliegue] skipped
{'pruebas': 'failed', 'imagen': 'skipped', 'despliegue': 'skipped'}
27b30ff
[pruebas] actions/checkout: ok · simulated action
[pruebas] astral-sh/setup-uv: ok · simulated action
[pruebas] dependencies: ok · lockfile up to date
[pruebas] linter: FAIL · src/estrategia.py:1 F401 np
[imagen] skipped
[despliegue] skipped
{'pruebas': 'failed', 'imagen': 'skipped', 'despliegue': 'skipped'}

The tests would catch the sign error, and so would the README example, but the runner stops at the first step that fails, like GitHub: pruebas fails in pytest (two of three tests), imagen and despliegue are skipped and the server keeps the previous version. The second commit passes the tests, but the unused import stops the linter before getting to them. A strict linter looks like a nuisance until a leftover import is the clue to a half-finished change. In a pull request (ref other than main) the image and deployment jobs are skipped by their if, and only the tests run: that is exercise 1.

Secrets and deployment

The deployment job needs a credential to get into the server. Lesson 4.4.5 covered the broker credentials: where they live, minimal permissions and rotation. The pipeline's credentials follow the same rules, with three nuances specific to GitHub:

  • One secret per purpose and with the minimum scope. The deployment SSH key only works for that server and for a user who can only run the deployment script (command= in authorized_keys). The broker keys do not go to GitHub: the server has them and the pipeline never sees them.
  • Environment secrets. Defined in the produccion environment, they are only visible to a job that declares that environment, and that job can require a reviewer's approval. GitHub redacts secrets in the logs, and its documentation asks you not to use structured data (a whole JSON) as the value of a secret so that redaction works7Note 7 · GitHub Docs, “Secrets reference” (https://docs.github.com/en/actions/reference/security/secrets)..
  • Deploy by digest. The imagen job publishes the image and records its digest; the deployment job asks the server for that exact digest (docker compose pull with the pinned image and up -d --wait), and keeps the previous one to roll back. The health check from lesson 7.2.1 decides whether the deployment ended well.

For a bot that trades, there is one more rule that no workflow brings by default: you do not deploy with the market open or with positions halfway through being managed. The deployment job can check it (a call to the bot itself that says whether it is in a safe state to stop, like the reconciled startup of lesson 4.5.5), and the protected environment adds a person who looks at it.

The real commands, which do not run in the course environment, would be these:

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)

Exercises

  1. Run the workflow on the last commit as if it came from a pull request (ref="refs/pull/7/merge"), first with the leftover import and then without it. Which jobs run?
Solution
IN [10]python
print(ejecutar(WORKFLOW, ref="refs/pull/7/merge"))
commit("src/estrategia.py", ESTRATEGIA, "remove the import",
       "2026-10-23T12:00:00Z")
print(ejecutar(WORKFLOW, ref="refs/pull/7/merge"))
OUT [10]The same workflow in a pull request
[pruebas] actions/checkout: ok · simulated action
[pruebas] astral-sh/setup-uv: ok · simulated action
[pruebas] dependencies: ok · lockfile up to date
[pruebas] linter: FAIL · src/estrategia.py:1 F401 np
[imagen] skipped
[despliegue] skipped
{'pruebas': 'failed', 'imagen': 'skipped', 'despliegue': 'skipped'}
[pruebas] actions/checkout: ok · simulated action
[pruebas] astral-sh/setup-uv: ok · simulated action
[pruebas] dependencies: ok · lockfile up to date
[pruebas] linter: ok · no findings
[pruebas] tests: ok · 3 passed
[pruebas] README examples: ok · ok
[imagen] skipped
[despliegue] skipped
{'pruebas': 'ok', 'imagen': 'skipped', 'despliegue': 'skipped'}

In a pull request only the tests run: the next two jobs have if: github.ref == 'refs/heads/main'. First they fail because of the linter; with the import removed, they pass, and the pull request turns green, ready for review. The deployment will happen when it is merged into main, not before.

  1. With latencia4_rtt.parquet, compute for each profile the minimum timeout (in ms, rounded up to the nearest ten) that leaves at most 0.1% of pings above it, not counting the lost ones. Why does it make no sense to use the same value for all of them?
Solution
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%} "
          f"above)")
OUT [11]One timeout per profile
retail_espana_nueva_york      510 ms  (0.100% above)
retail_espana_frankfurt       190 ms  (0.100% above)
vps_nueva_jersey               10 ms  (0.100% above)
nube_misma_region              10 ms  (0.100% above)

The values range from 10 ms on the VPS and the cloud to 510 ms from home to New York. A common timeout would be either too short for the home connection (false timeouts and resends) or too long for the VPS (the bot would be slow to notice a real outage). That is why the timeout is configuration of each deployment, measured from its machine, and not a constant in the code.

  1. A colleague proposes automatically deploying every push to main to the server where the bot trades with real money, “because the tests already pass”. What would you add before accepting it?
Solution

That passing the tests is a necessary condition, not a sufficient one. I would add: a protected environment with a reviewer who approves the deployment; a deployment window outside market hours, checked by the job itself; the bot confirming it is in a safe state (no live orders pending confirmation, positions reconciled) before stopping it; deployment by digest, with the previous digest saved to roll back in one command; and paper trading of the same image for a period before moving it to the real account (lesson 4.5.3). And, if the change affects the strategy and not only the code, the validation of lesson 7.3.2.

Footnotes

  1. ICE, “ICE Global Network & Colocation” (https://www.ice.com/fixed-income-data-services/access-and-delivery/global-network), accessed on October 23, 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), accessed on October 23, 2026: free usage in public repositories with standard runners; 2,000 minutes a month on GitHub Free and GitHub Free for organizations, 3,000 on Pro and Team. ↩

  3. GitHub Docs, “Workflow syntax for GitHub Actions”: permissions, timeout-minutes (“Default: 360”), needs and 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”, sections “Use an intermediate environment variable” and “Mitigating the risks of untrusted code checkout”. ↩

  6. GitHub Docs, “Workflow dependency lockfiles” (https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles), accessed on October 23, 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). ↩

Each answer is folded under its question

Self-assessment

  1. A bot on a VPS in Madrid trades on an exchange whose data center is in Mahwah (New Jersey). What is the physical minimum round trip over fiber?

    • AAbout 56 ms: 5,761 km of great-circle distance, there and back, at the speed of light in glass (c divided by 1.47); over a real cable, more
    • BAbout 6 ms, the time a packet takes to leave the network card
    • CAbout 39 ms, the time light takes in a vacuum
    • DIt depends only on the Internet provider
    Show answer

    Answer A. About 56 ms: 5,761 km of great-circle distance, there and back, at the speed of light in glass (c divided by 1.47); over a real cable, more

    The Madrid-Mahwah great-circle distance is about 5,761 km. At 299,792 km/s divided by 1.47, the minimum round trip is 56.5 ms; 38.4 ms would be in a vacuum, which no cable reaches. Real cables do not follow the great circle, so real latency is higher.

  2. The latency profile from home to New York has a median of 88.5 ms. The bot waits 200 ms for the response to each order before considering it lost. What happens?

    • AThere are never false timeouts: 200 ms is more than twice the median
    • BThere are constant timeouts
    • C0.87% of the messages exceed 200 ms or are lost; with 2,000 requests a day that is about 17 late responses the bot believes are lost, and each one is an order it might resend
    • DTimeouts only matter in high frequency
    Show answer

    Answer C. 0.87% of the messages exceed 200 ms or are lost; with 2,000 requests a day that is about 17 late responses the bot believes are lost, and each one is an order it might resend

    The median says nothing about the tail. In the data, 0.87% of the pings from home to New York take more than 200 ms or never arrive. A response that arrives late is not a lost order: that is why the idempotency key of lesson 4.5.5 is needed before resending.

  3. What does GitHub's security documentation recommend for using a third-party action in a workflow, if the repository does not use the workflow dependency lockfile?

    • AUse the @v7 tag, which always points to the latest safe version
    • BCopy the action into the repository
    • CUse @main to have the fixes up to date
    • DPin it to a full commit SHA, checking that it is from the action's repository and not from a fork: a tag can be moved or deleted if someone takes over the repository
    Show answer

    Answer D. Pin it to a full commit SHA, checking that it is from the action's repository and not from a fork: a tag can be moved or deleted if someone takes over the repository

    GitHub recommends the full SHA because moving a tag is trivial for whoever controls the repository, and forging a commit would require a SHA-1 collision. Since 2026 there is also a workflow dependency lockfile (.github/workflows/actions.lock), in public preview on October 23, 2026, which GitHub presents as the strongest protection.

  4. A run: step contains echo ${{ github.event.pull_request.title }}. What is the problem?

    • ANone: GitHub always escapes expressions
    • BThe expression is substituted into the script text before running it: a title such as a"; curl … runs as a command. The value has to be passed through an intermediate environment variable
    • COnly that the title may be empty
    • DThat the github context does not exist in run steps
    Show answer

    Answer B. The expression is substituted into the script text before running it: a title such as a"; curl … runs as a command. The value has to be passed through an intermediate environment variable

    It is the script injection described in GitHub's security guide. With env: TITLE: ${{ … }} and "$TITLE" in the script, the value stays in memory as a variable and does not mix with the code. The same goes for secrets: into env, never inside the command text.

  5. In the local runner, the commit that flips the sign of the signal makes pytest fail. What does the pipeline do with the following jobs?

    • AIt runs them anyway, in parallel
    • BIt runs the build but not the deployment
    • CIt skips them: a job with needs does not run if the job it needs fails, and the server keeps the previous version
    • DIt deploys the previous version automatically
    Show answer

    Answer C. It skips them: a job with needs does not run if the job it needs fails, and the server keeps the previous version

    According to the workflow syntax reference, if a job fails or is skipped, the jobs that need it are skipped unless they use a condition such as always(). Deployment only happens with all checks green, and the bot in production never sees the broken commit.

Key points

  • The horizon rules: a daily strategy can live on any VPS; an intraday one on minutes, in the broker's region; below a millisecond you need colocation, which is another business.
  • Light in fiber covers about 204 km per millisecond. Madrid-Mahwah is 5,761 km of great-circle distance: at least 56.5 ms round trip; Ashburn-Mahwah, 3.6 ms; inside the same data center, microseconds.
  • The tail matters more than the median: from home to New York, 0.87% of the messages take more than 200 ms or are lost, and from a VPS in New Jersey none. Timeouts are set with high percentiles, and orders are resent only with idempotency.
  • A secure workflow: actions pinned by full SHA (or the workflow lockfile), minimal permissions, timeout-minutes, secrets only in env, no pull_request_target with outside code, and uv sync --locked.
  • Deployment depends on the tests (needs) and goes to a protected environment with reviewers; a commit that breaks a test or the linter does not reach the server.
  • Public repositories use GitHub's standard runners for free; in private ones, the Free plan includes 2,000 minutes a month (GitHub documentation, October 23, 2026).

Resources