2. The minimum structure of a project you can operate
Level 7: From the notebook to the process
A notebook mixes everything into a single vertical column. A system separates the responsibilities into pieces you can inspect, test and replace one at a time. This is not architect's bureaucracy: it is what decides which parts of your code you can run a thousand times with no consequences and which parts touch money.
Five responsibilities, five modules
The minimum structure fits into five files, and it is best that they really are five:
- datos. Downloads, caches and validates prices and fundamentals. Returns tables. It does not know what a signal is.
- senal. Takes tables, returns an abstract decision: get in, get out, do nothing. It does not know a broker exists.
- dimension. Turns the decision into a number of shares, given the capital and the risk limits. It does not know where the decision came from.
- ejecucion. Translates that number into concrete calls to the broker's interface and returns whatever the broker replies.
- registro. Writes down what was decided, with which inputs, and what the broker replied.
And above them, a sixth file that computes nothing: the entry point, which wires them together in order.
The criterion for splitting is not the number of lines, it is the reason each piece changes. The data module changes when you change provider; the signal one, when you change your mind; the execution one, when the broker updates its interface. Those are three independent reasons, on three independent timetables, and that is why they live in three files. When two pieces that change for different reasons share a file, touching one forces you to revalidate the other.
Why the strategy does not call the broker
This is the rule that generates the most resistance and pays off the most. If the signal function queries the current position through the broker's interface, three things happen at once: you cannot run it in a test without a network, its result depends on external state that changes on its own, and you can no longer walk ten years of history through it by feeding it data. The function stops being a function and becomes a procedure with effects.
# senal.py -- pure logic: same inputs, same output, always
def calcular_senal(precios, ventana_corta=20, ventana_larga=100):
media_corta = precios['cierre'].rolling(ventana_corta).mean()
media_larga = precios['cierre'].rolling(ventana_larga).mean()
if media_corta.iloc[-1] > media_larga.iloc[-1]:
return 'largo'
return 'plano'That function downloads nothing, writes nothing and sends nothing. That is why it can be tested in microseconds, with made-up data, and why the same code that runs in the backtest can run in production without touching a line. What has no side effects can be repeated; what has effects must be isolated.
A single entry point, runnable from the command line
If there are three ways to launch the strategy in the team, there are three strategies. The entry point is a file that runs with one command in the terminal, accepts explicit arguments and contains no business logic: it only orchestrates.
# ejecutar.py
import argparse
from datos import cargar_precios
from senal import calcular_senal
from dimension import acciones_objetivo
from ejecucion import enviar_orden
from registro import anotar
def main():
p = argparse.ArgumentParser()
p.add_argument('--simbolo', required=True)
p.add_argument('--capital', type=float, required=True)
p.add_argument('--simular', action='store_true')
args = p.parse_args()
precios = cargar_precios(args.simbolo)
decision = calcular_senal(precios)
cantidad = acciones_objetivo(decision, args.capital, precios)
anotar('decision', simbolo=args.simbolo, decision=decision, cantidad=cantidad)
if args.simular:
anotar('simulado', cantidad=cantidad)
return
respuesta = enviar_orden(args.simbolo, cantidad)
anotar('respuesta', datos=respuesta)
if __name__ == '__main__':
main()Notice three details. The arguments are mandatory, so nobody inherits yesterday's capital by accident. The simulation mode walks exactly the same path and stops just before the single step that touches the world. And the final condition lets you import the file from a test without anything running.
Notice too what is not there: not a single calculation, threshold or business condition. The entry point only reads arguments, calls in order and records. When conditions of the kind *if the symbol is this one then the limit is that one* start appearing there, the logic is leaking into the one file nobody tests, and that file ends up being the longest in the project.
The test that is only possible if you separate
With this structure, checking the strategy requires no network, no keys and no open market:
# test_senal.py
import pandas as pd
from senal import calcular_senal
def test_cruce_al_alza_da_largo():
cierres = list(range(100, 130)) + list(range(130, 100, -1))
precios = pd.DataFrame({'cierre': cierres})
assert calcular_senal(precios, 3, 10) == 'plano'You can run it a thousand times in a row on a machine with no internet. That is the operational test of whether you have separated properly: if testing a part requires credentials, you have not separated it.
Real case: Knight Capital, August 1, 2012
Knight Capital was one of the largest market makers in the United States. That morning it deployed new code to its order-routing servers. The deployment was done by hand, server by server, and on one of the eight the new version never got copied across. Worse: the new code reused a configuration flag that in the old version switched on a feature retired years earlier and which was still living inside the same executable.
On that straggler server, the flag woke the old code up. The system began firing orders into the market at an enormous rate as soon as the session opened. In about forty-five minutes the firm racked up a loss of roughly 440 million dollars, more than the company itself was worth; it had to be rescued within days and ended up absorbed by a competitor. The SEC additionally imposed a 12 million dollar fine in 2013.
There are many lessons in there and several belong to later levels. This module's one is specific: dead code still packaged alongside the live code, and a flag that means two different things depending on which version reads it. When responsibilities are not separated, no component knows enough to refuse.