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.
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.
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.
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
Services start through a Python launcher with a double fork; systemd restarts them and keeps all logs in one place
A watchdog for each event type, an alert when an external API changes its format, a status summary
Replaying historical records: real price moves, duplicates removed, results split by market regime
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 hereset -mwith 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
Ordinary tools and strict rules
WebSocket clients, decision loops and watchdog tasks that run next to the working code
The reaction starts from the event; polling is only a catch-up request every 5 s
All data in a database, a data_provenance table, one writer plus read-only access for analysis
10 services on one server, restarted without a person, logs in one place
Background jobs on macOS that always detach (PPID=1), with AbandonProcessGroup
Syncing the record archive, checking outcomes, regular reconciliation
Only where a consumer really needs the raw stream; everything else goes to the database
Each bug is closed with the test that would have caught it, so it doesn't come back
The status of every service and the minutes since the last event in each stream
Rules with the date they were proven, the incident behind them and when they stop applying
The system now reports its own failures
a watchdog for each event type plus an alert when a data format changes
on one server, restarted automatically, logs in one place
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.
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
- 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
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.
Related cases and ready-made solutions
Selective traffic routing through my own server
Some external services go through a server I rent, and the rest of the internet goes direct. Routes are set…
An online shop audit and the three fixes that, by my estimate, pay off first
In a two-week audit I work out where key staff spend their time and draw up a list of tasks with a payback…
I stress-test AI chatbots and find the vulnerabilities before anyone exploits them
I attack a copy of the system following the OWASP LLM Top 10, a public list of typical threats to AI…