Skip to content
VC
Case Study #26 · Python · Data

Autonomous bot fleet and backtest engine: reliability engineering

Personal, internal R&D in an NDA fintech domain: ten autonomous services that make real-time decisions off an external event stream. This case is not about returns — it's about the engineering: data collection with provenance, daemons that don't die quietly, backtesting against real history instead of faith in a pretty curve, and a risk loop that shuts things down before a human would.

Industry
Fintech (NDA) · own R&D
Stack
Python · asyncio · WebSocket · DuckDB / SQLite
Format
Personal tooling, not client work
Outcome
Silent failures became visible
01 · Pain Point

An autonomous system breaks quietly

A background process has no user to complain. It keeps spinning, keeps writing logs and keeps honestly reporting "alive" — while it no longer receives data and makes decisions off stale state. Hours pass between the break and its discovery, and the whole time the system looks healthy.

Three real scenarios from this project. First: an external API changed a single word in an instrument name — the parser stopped recognising part of the stream, and the breakage was noticed 10 hours later. Second: socket open, no exceptions, events flowing — but one specific event type hadn't arrived for 20+ minutes; the generic "last event" watchdog stayed silent because the other types kept coming. Third: a watchdog logged restarts=0 failures=0 for hours while two processes were dead — pgrep -f without an anchor was matching neighbouring shells whose argv contained the pattern, including the checking script itself.

And a fourth class, the most expensive one: the system doesn't crash, it quietly lies. The offline simulator determined trade outcomes from the wrong reference moment — agreement with the real settlement rule turned out to be 51%, i.e. a coin flip. Everything built on top of that simulator looked like analysis for months and was noise. Until someone checked the settlement rule itself, there was no way to tell the two apart.

02 · Solution

Five loops, each one verifiable on its own

A real-time decision system isn't "an algorithm". It's five independent loops: data collection, daemon liveness, observability, offline hypothesis testing, and risk control. Any one of them can break on its own — so each has to be verifiable without the others.

01
Collection

Event-driven WS stream + 5s REST catch-up. Every row lands in SQL with provenance: source, ingest time, writer

02
Daemons

Python double-fork launcher instead of bash detach, systemd Restart=always + journald

03
Observability

A watchdog per event type, a canary for external-API schema drift, a status digest

04
Backtest

Excursion replay over historical ledgers: real price excursions, dedup, regime segmentation

05
Risk

Scheduled multi-layer monitor, HALT marker file as kill-switch, resume only through the script

Bash cannot detach a daemon on macOS — tested, not assumed

Restarting a process from a script looks trivial right up to the first hang. Five backgrounding patterns were tested empirically, and all five left the parent shell stuck in __wait4 on the supposedly disowned child — one process hung that way for 1 h 32 min:

  • nohup … & disown — the classic that doesn't work
  • set -m + disown, subshell launch, nested fork variants — same result
  • macOS ships no setsid(1), so there is no "correct" shell answer at all

The fix is one shared Python launcher: fork → setsid → fork → execvp, with the grandchild guaranteed to end up at PPID=1. Every restart in the project goes through it; launchd jobs additionally set AbandonProcessGroup, or the OS reaps the children along with the script that spawned them.

Liveness is measured per event type, not per process

The baseline watchdog tracks the timestamp of the last event of any type and kills the process if silence exceeds 360 seconds — a threshold found empirically, since 180 s produced false positives on normal cycle gaps. The supervisor brings the service back, so the watchdog can afford to simply exit with a distinct return code.

But that only cures a fully dead connection. The real incident was subtler: one event type stopped arriving while everything else flowed as usual — and the generic silence counter never fired. Hence the rule now applied to every service: liveness is not "is the process up", it's "when did I last receive the thing I exist for". Every critical event type gets its own counter, the health report shows "minutes since last" per stream, and there's a preventive re-subscribe on a timer.

A liveness check has to be honest

pgrep -f patterns must be anchored to the end of argv (…/book_recorder\.py$). Without the anchor the check matches any neighbouring shell that happens to carry the string in its command line — including the calling script itself. Monitoring that counts itself as the live process is worse than no monitoring: it reports green on a dead system.

SQL-first, with provenance on every row

The rule for all new writers: data goes to SQL, and JSONL is allowed only as a rolling buffer with ≤48 h retention and daily rotation. Every dataset gets a row in data_provenance (source, data status, audit notes, whether it's safe to delete); every record carries ingested_at and a link back to its source dataset. The trigger was mundane: an audit found tens of gigabytes of duplicates that nobody could delete, because nobody could prove where they came from.

  • live writers open the connection on flush and close it immediately — the lock is held for milliseconds instead of the writer's entire lifetime
  • a continuously written database is single-writer; analyst connections are read_only=True only
  • a new live writer gets its own database file rather than sharing one with an existing writer

Decision records are never deleted

The postmortem that produced a rule of its own: roughly five weeks of decision history vanished under disk pressure — routine archive rotation removed files that cannot be reconstructed. The answer is append-only storage: harvest every entry and close record from live runs and from archives, dedup by line hash (idempotent, so it can run every 15 minutes), sync to an offsite copy. The key rule is baked into the pruning code: harvesting runs before any deletion, and if it fails, the deletion is cancelled entirely. Disk is cheaper than data, always.

Event-driven reaction instead of timer polling

A 30-second poll is a perfectly good design when events are rare. Here the decision windows live for seconds, so reaction is driven by the incoming WS event and REST stays a catch-up scan on a 5 s interval. The full path is measured component by component, so latency arguments can be settled with numbers rather than opinions:

  • connect and subscribe — ~40 ms; detail request — ~26 ms; external state read — ~35 ms; submit — ~41 ms
  • cryptographic signing — 0.24 ms after swapping the pure-Python backend for a native one; before that it was three orders of magnitude more expensive and ate the entire budget
  • background caches and parallel requests instead of sequential waits — tens of milliseconds more
  • fast path end to end — 250–350 ms; you can't go lower without an architecture change, the rest is network-bound
03 · Stack

Nothing exotic — it all runs on discipline

Python 3 · asyncio

WS clients, decision loops, watchdog coroutines living next to the working code

WebSocket + REST fallback

Event-driven reaction as the primary path, polling only as a 5s catch-up scan

DuckDB / SQLite

SQL-first storage, data_provenance, single-writer plus read-only analyst access

systemd · Restart=always · journald

10 services on one host, restarts without human hands, logs in one place

launchd + double-fork launcher

Background jobs on macOS with guaranteed PPID=1 and AbandonProcessGroup

Cron / timers

Ledger vault sync, outcome resolvers, periodic reconciliation

JSONL rolling buffer (≤48 h)

Only where a consumer genuinely needs the raw stream; everything else goes to SQL

A regression test per fix

A bug is closed by the test that would have caught it — otherwise it comes back

Messenger digest

Status of every service plus "minutes since last" per event stream

Invariants log (I-NNN)

Rules with the date they were proven, the incident, and their invalidation conditions

PythonasyncioWebSocketDuckDBSQLitesystemdjournaldlaunchdcrondouble-forkwatchdogdata_provenance
04 · Results

The system didn't become unbreakable — it became honest

Silent-failure detection threshold
10 hrs 6 min

per-event-type watchdog threshold plus a schema-drift canary

Services under supervision
10

on one host, Restart=always + journald, restarts with no human in the loop

Offline simulator accuracy
51% 100%

agreement with the real settlement rule after the fix — across the verified sample, which is still small

An invariants log instead of oral tradition

Every rule that gets discovered is written down as a numbered invariant: the date it was proven, the incident that produced it, a "how to apply" section, and — as a separate item — the conditions under which the rule stops being true. Rules don't come from opinion: they come from incidents and carry a way to reproduce them. That's what stops the same mistake from being rediscovered six months later on a new service.

A backtest that doesn't flatter you

Validating a fix on a live run means waiting days and still having no statistics. Instead, each fix is replayed over historical ledgers using excursion replay: every closed record stores the real extremes of the move, so the counterfactual is computed from how things actually moved rather than from a re-simulation. The method is fixed as a set of non-negotiable rules:

  • assumption-free metrics first — distributions and hit rates at each level; if there's no movement at all, no exit tuning will help
  • dedup replicas: the same signal is duplicated across configurations roughly 12 times — without dedup the sample is fake-inflated
  • segment by market regime and by signal type; a blended number hides a system that wins in one regime and loses in another
  • bounds for ambiguous cases: when the intra-bar ordering is unknown, compute the optimistic and pessimistic estimate and accept the conclusion only if both agree
  • random-entry control: the same exit logic on random entries must lose — otherwise the "effect" came from the exit mechanics, not the signal
  • n ≥ 30 per cell, out-of-sample and walk-forward folds, and a multiple-testing correction

The practical effect is unpleasant and useful at the same time: most ideas that looked like they worked don't survive this method. Of eight mechanical strategies tested, two survived. That is the result — knowing the other six don't work before paying for them.

The risk loop halts before a human would

A multi-layer monitor runs on a schedule and checks several independent halt conditions. A halt is expressed as a marker file that every process can see — a simple, dependency-free kill-switch. After that it's discipline: halt markers are never hand-edited, and resuming goes only through the dedicated script that checks why the halt happened. New filters get their own rule: audit mode first, where the filter only logs its decision, and only after enough data does it earn the right to block.

The overall outcome, stripped of detail: the numbers the system produces can be trusted, and failures became visible in minutes rather than hours. For an autonomous system that is the headline feature — everything else is built on top of it.

05 · Where it fits

Where else the same methodology applies

This case isn't about trading. It's the generic problem of "an autonomous process makes decisions off an external event stream, and nobody complains when it breaks". The loop transfers almost unchanged:

  • External API integrations where the format changes without warning — a schema-drift canary instead of a postmortem
  • Background workers and queues where "the process is up" ≠ "work is happening": liveness per event type, not per PID
  • ML and analytics where offline evaluation disagrees with production — check how the target metric is computed before touching the model
  • Autonomous LLM agents that spend money or take actions — they need the same kill-switch, audit mode and limits
  • Auditable datasets — row-level provenance is the only way to answer, six months later, where a number in a report came from
  • Telemetry, IoT, logistics — the same class: event streams, tight reaction windows, failures without a single error message
What's reused on subsequent projects
  • The double-fork launcher plus systemd and launchd unit templates — background jobs that genuinely detach
  • Per-event-type watchdogs, an external-API schema-drift canary, and a health report with time-since-last-event
  • The SQL-first layer with data_provenance, the open-on-flush pattern and a writer / read-only split
  • Append-only record storage: harvest before any pruning, cancel the prune if harvesting fails, keep an offsite copy
  • The offline validation method: dedup, segmentation, bounds for ambiguous cases, random-entry control
  • The invariants-log format: date proven, incident, how to apply, and when the rule expires
Similar challenge?

If a process runs unattended, the question isn't whether it breaks — it's whether you'll find out

Two measurements are the sensible place to start: how many minutes until you learn about a silent failure, and whether your offline evaluation computes the same thing production does. Those two checks usually surface the first two or three holes — before the holes surface themselves.

Ready to start?

The 5,000 ₽ audit — with a concrete report and quote

I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).

Or just send your question — I reply within 2 hours