---
title: "7.2.2 VPS, cloud, latency and CI/CD"
description: "VPS or cloud for a bot, the physical latency floor between real cities, measured tails and a GitHub Actions CI/CD pipeline validated and run locally."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/vps-cloud-latency-ci-cd/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/vps-cloud-latency-ci-cd.png"
---

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

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- Module: 7.2 · Infrastructure and deployment
- Duration: 70 min
- Prerequisites: [0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore](https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/), [4.3.2 · Order flow imbalance, microstructure signals and latency](https://pythonparatrading.com/en/python-trading-course/level-4/order-flow-imbalance-and-latency/), [4.4.5 · Secure credentials: environment variables, secret managers and least privilege](https://pythonparatrading.com/en/python-trading-course/level-4/secure-api-credentials-and-least-privilege/), [4.5.5 · Fault tolerance: idempotency, persistent state and reconciliation at startup](https://pythonparatrading.com/en/python-trading-course/level-4/fault-tolerance-idempotency-and-reconciliation/), [7.2.1 · Docker and docker-compose: reproducible images with uv](https://pythonparatrading.com/en/python-trading-course/level-7/docker-compose-reproducible-images-uv/)

## By the end of this lesson

- Choose between home, VPS, cloud and colocation according to the strategy's horizon, and locate the providers' regions relative to the markets.
- Compute the physical latency floor between two cities with the great-circle distance and the speed of light in a vacuum and in fiber.
- Measure the tail of a latency distribution and translate it into timeouts, false timeouts and the risk of duplicate orders.
- Write a GitHub Actions workflow with uv, tests, linter and deployment, and validate it against GitHub's security rules: actions pinned by SHA, minimal permissions, secrets out of the scripts.
- Run a CI pipeline locally on a test repository and see how a failure stops the deployment.

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

| Where | What you gain | What you lose | For which horizon |
|---|---|---|---|
| Home | zero cost, full control | power, router, dynamic IP, outages | research, paper, daily with supervision |
| VPS | machine always on, fixed IP, fixed monthly price | resources shared with other customers | daily and intraday on minutes |
| Cloud (broker's region) | regions next to the gateways, managed services | pay-per-use billing, more moving parts | intraday on seconds |
| Colocation | microseconds to the matching engine | contracts with the market, own hardware | another 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:

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

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

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

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

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

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

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

```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 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"))
```
```output
f9a7fbc initial bot
```

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

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

```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)
```

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

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

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

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

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

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

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

   - 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
   - B. About 6 ms, the time a packet takes to leave the network card
   - C. About 39 ms, the time light takes in a vacuum
   - D. It depends only on the Internet provider

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

   - A. There are never false timeouts: 200 ms is more than twice the median
   - B. There are constant timeouts
   - 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
   - D. Timeouts only matter in high frequency

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

   - A. Use the `@v7` tag, which always points to the latest safe version
   - B. Copy the action into the repository
   - C. Use `@main` to have the fixes up to date
   - 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

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

   - A. None: GitHub always escapes expressions
   - 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
   - C. Only that the title may be empty
   - D. That the `github` context does not exist in `run` steps

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

   - A. It runs them anyway, in parallel
   - B. It runs the build but not the deployment
   - C. It skips them: a job with `needs` does not run if the job it needs fails, and the server keeps the previous version
   - D. It deploys the previous version automatically

   *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

### Documentation

- [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 (list of regions)](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

### Data

- [Level 4 data: latencies of six connection profiles (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/latencia4_rtt.parquet) · Download · latencia4_rtt.parquet

Previous lesson: [7.2.1 · Docker and docker-compose: reproducible images with uv](https://pythonparatrading.com/en/python-trading-course/level-7/docker-compose-reproducible-images-uv/)

Next lesson: [7.2.3 · Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats](https://pythonparatrading.com/en/python-trading-course/level-7/orchestration-and-monitoring-trading-systems/)
