Skip to content

Allow this site to count your visits with Yandex Metrica? Metrica will store cookies in your browser to do this. How the site uses cookies

VC
Case 18 of 25 · Python · Data

Autonomous bots and backtesting: how to make them reliable

This is my own fintech project, and I keep its details private. Ten autonomous services make real-time decisions based on a stream of external events. This case is about the engineering: every record shows where it came from, background services don't die unnoticed, ideas are tested on real history, and a risk check shuts the system down before a person would notice a problem.

Industry
Fintech · my own project
Technology
Python · asyncio · WebSocket · DuckDB / SQLite
Format
My own tool for my own work
Outcome
Silent failures are now visible
01 · Problem

An autonomous system breaks quietly

A background process has no user to complain. It keeps running, keeps writing logs and keeps reporting "alive". Meanwhile it has lost its data feed and makes decisions from stale information. Hours pass between the failure and its discovery, and all that time the system looks healthy.

Three real cases from this project. First, an external API changed one word in an instrument name. The parser stopped recognising part of the stream, and the failure was noticed 10 hours later. Second, the connection was open, there were no errors and events kept flowing, yet one specific event type hadn't arrived for more than 20 minutes. The general watchdog tracked "the last event of any kind" and stayed silent because the other types kept coming. Third, a watchdog logged restarts=0 failures=0 for hours while two processes were dead. The pgrep -f check had no anchor, so it matched neighbouring shells whose command line contained the pattern, including the checking script itself.

The fourth case was the most expensive: the system kept running and quietly produced wrong numbers. The simulator that replayed history judged trade outcomes at the wrong moment. It agreed with the real settlement rule in only 51% of cases, no better than a coin toss. For months everything built on that simulator looked like analysis, and it was noise. Nobody could tell the difference until someone checked the settlement rule itself.

02 · Solution

Five parts, each checked on its own

A real-time decision system has five independent parts: data collection, keeping background services alive, monitoring, testing ideas on history, and risk control. Each of them can break on its own, so each has to be checked on its own.

01
Collection

The main event stream (WebSocket) plus a catch-up request every 5 s. Every database row records its source, load time and the process that wrote it

02
Background services

Services start through a Python launcher with a double fork; systemd restarts them and keeps all logs in one place

03
Monitoring

A watchdog for each event type, an alert when an external API changes its format, a status summary

04
Backtesting

Replaying historical records: real price moves, duplicates removed, results split by market regime

05
Risk

A multi-level check on a schedule, a HALT marker file as the emergency stop, restart only through a script

Why background jobs on macOS start through Python

Restarting a process from a script looks simple until the first hang. I tested five ways of starting a process in the background. In all five the parent shell stayed stuck in __wait4 on the child it had supposedly let go. One process hung like that for 1 h 32 min:

  • nohup … & disown: the classic way, which didn't work here
  • set -m with disown, a subshell launch, nested forks: the same result
  • macOS has no setsid(1) command, so one standard shell command can't solve this

The fix is one shared Python launcher: fork → setsid → fork → execvp. The grandchild process always ends up with PPID=1 and runs independently of the shell. Every restart in the project goes through it. For macOS jobs (launchd) I also set AbandonProcessGroup; otherwise the system kills the children together with the script that started them.

Each event type has its own sign of life

The basic watchdog tracks the time of the last event of any type. It stops the service if nothing has arrived for more than 360 seconds. I found that threshold in practice: at 180 s it fired on normal pauses. The service manager brings the service back, so the watchdog only needs to exit with a special return code.

That catches only a fully dead connection. The real incident was subtler: one event type stopped arriving while everything else flowed as usual, and the general silence counter never fired. Hence the rule now used for every service: a service is alive if it recently received the thing it exists for. Each important event type has its own counter. The health report shows how many minutes ago the last event arrived in each stream, and the service re-subscribes to the stream on a timer as a precaution.

A "process is alive" check has to be honest

pgrep -f patterns must be anchored to the end of the command line (…/book_recorder\.py$). Without the anchor the check matches any neighbouring shell whose command happens to contain the string, including the calling script itself. Such monitoring counts itself as the live process and shows green on a dead system.

Data goes to a database, and every row shows where it came from

The rule for all new code: data goes into a database. JSONL files are allowed only as a temporary buffer, kept for no more than 48 hours, with a new file each day. Every dataset has a row in data_provenance: source, status, review notes and whether it's safe to delete. Every record carries ingested_at and a link to its source dataset. The reason was mundane: a review found tens of gigabytes of duplicates that nobody could delete, because nobody could prove where they came from.

  • processes that write live data open the connection only to save and close it right away, so the lock lasts milliseconds
  • a database that is written to all the time has one writer; analysis connects only with read_only=True
  • each new process that writes live data gets its own database file

Decision records are never deleted

One incident review produced a rule of its own. About five weeks of decision history disappeared when the disk filled up: routine archive cleanup removed files that can't be rebuilt. The answer is storage that is only ever added to. Every entry and exit record is collected from live runs and from archives, duplicates are removed by line hash (so the job is safe to run every 15 minutes), and a copy goes to a separate location. The key rule is built into the cleanup code: collection runs before any deletion, and if collection fails, the deletion is cancelled.

The reaction starts from the event itself

Polling every 30 seconds is a perfectly good design when events are rare. Here a decision window lasts seconds, so the reaction starts from the incoming event, and a regular request every 5 s only picks up what was missed. The full path is measured step by step, so arguments about speed are settled with numbers:

  • connect and subscribe: ~40 ms; details request: ~26 ms; reading external state: ~35 ms; sending: ~41 ms
  • cryptographic signing: 0.24 ms after switching from a pure-Python library to a native one; before that it took about a thousand times longer and used up the whole time budget
  • background caches and parallel requests in place of waiting in sequence saved tens of milliseconds more
  • the whole fast path: 250–350 ms. Going lower would take a different architecture; the rest is network time
03 · Technology

Ordinary tools and strict rules

Python 3 · asyncio

WebSocket clients, decision loops and watchdog tasks that run next to the working code

WebSocket + REST fallback

The reaction starts from the event; polling is only a catch-up request every 5 s

DuckDB / SQLite

All data in a database, a data_provenance table, one writer plus read-only access for analysis

systemd · Restart=always · journald

10 services on one server, restarted without a person, logs in one place

launchd + double-fork launcher

Background jobs on macOS that always detach (PPID=1), with AbandonProcessGroup

Cron / timers

Syncing the record archive, checking outcomes, regular reconciliation

JSONL rolling buffer (≤48 h)

Only where a consumer really needs the raw stream; everything else goes to the database

A regression test per fix

Each bug is closed with the test that would have caught it, so it doesn't come back

Messenger summary

The status of every service and the minutes since the last event in each stream

Invariants log (I-NNN)

Rules with the date they were proven, the incident behind them and when they stop applying

PythonasyncioWebSocketDuckDBSQLitesystemdjournaldlaunchdcrondouble-forkwatchdogdata_provenance
04 · Results

The system now reports its own failures

Time to detect a silent failure
10 hrs 6 min

a watchdog for each event type plus an alert when a data format changes

Services under watch
10

on one server, restarted automatically, logs in one place

Simulator agreement
51% 100%

with the real settlement rule after the fix, on the sample checked so far, which is still small

Every rule goes into a log of invariants

Every rule I discover is written down as a numbered invariant: the date it was proven, the incident that produced it, how to apply it and, as a separate item, when the rule stops being true. Each rule comes from an incident and includes a way to reproduce it. That keeps the same mistake from being rediscovered six months later on a new service.

An honest backtest

Checking a fix on live runs means waiting for days and still having too little data. So each fix is replayed over the historical records. Every closed record stores the real highs and lows of the price move, and the "what if" result is calculated from how prices actually moved, with no new simulation. The method has fixed rules:

  • first, measures that need no assumptions: distributions and how often each level is reached. If the price doesn't move, no exit setting will help
  • remove copies: the same signal appears in about 12 configurations, and without removing the copies the sample looks bigger than it is
  • split results by market regime and by signal type: one overall number hides a system that wins in one regime and loses in another
  • bounds for unclear cases: when the order of prices inside a candle is unknown, calculate the optimistic and the pessimistic result and accept a conclusion only if both agree
  • a random-entry check: the same exit rules applied to random entries must lose money. If they win, the "effect" came from the exit rules themselves
  • at least 30 cases per group, tests on data the idea wasn't built on, rolling forward tests, and a correction for testing many ideas at once

In practice this is both unpleasant and useful: most ideas that seemed to work don't pass. Of eight rule-based strategies tested, two passed. That is the result: knowing that the other six don't work before paying for them.

The risk check stops the system before a person would

A multi-level check runs on a schedule and looks at several independent stop conditions. A stop is a marker file that every process can see: a simple emergency switch with no dependencies. The rest is discipline. Stop markers are never edited by hand, and the system restarts only through a dedicated script that checks why it stopped. New filters follow their own rule: first a trial mode, where the filter only logs its decision, and only after enough data does it get the right to block.

In short, the numbers the system produces can be trusted. Failures that used to go unnoticed for hours now show up within minutes. For an autonomous system this matters most, and everything else is built on it.

05 · Where it fits

Where the same approach works

Under the trading details lies a common problem: an autonomous process makes decisions from a stream of external events, and nobody complains when it breaks. The same setup carries over almost unchanged:

  • → Integrations with external APIs whose format changes without warning: an alert on format changes flags the problem before it does damage
  • → Background workers and queues, where a running process may be doing no work: check for signs of life per event type
  • → Machine learning and analytics, where test results disagree with live results: check how the target metric is calculated before touching the model
  • → Autonomous AI agents that spend money or take actions: they need the same emergency stop, trial mode and limits
  • → Data that gets audited: when every row records its source, you can say six months later where a number in a report came from
  • → Telemetry, connected devices, logistics: the same kind of problem, with event streams, short reaction windows and failures that produce no error message
What I reuse on later projects
  • The double-fork launcher and ready templates for systemd and launchd: background jobs that really detach
  • Watchdogs for each event type, an alert when an external API changes its format, and a health report with the time since the last event
  • Database storage with data_provenance, connections opened only to save, one writer and read-only access for everyone else
  • Record storage that is only added to: collect before any cleanup, cancel the cleanup if collection fails, keep a copy elsewhere
  • The backtest method: removing copies, splitting results, bounds for unclear cases, a random-entry check
  • The invariants log format: date proven, incident, how to apply, and when the rule stops applying
Similar challenge?

If a process runs unattended, the question is whether you'll know when it breaks

Start with two measurements: how many minutes it takes you to learn about a silent failure, and whether your test calculations match what happens live. These two checks usually reveal the first two or three weak spots before they cause trouble.