When to Use
Invoke this skill when a bot that can send orders runs as a systemd unit on a Linux host, and you need to answer three questions that a unit file cannot answer by looking healthy:
- Will the crash-loop brake actually engage? Not "is
StartLimitBurst=present" — will systemd reach it before the bot has hammered a degraded broker for an hour. - Is the watchdog watching anything? A
WatchdogSec=that systemd honours while everyWATCHDOG=1is discarded is worse than no watchdog: it kills a healthy bot on a timer. A ping sent from a thread that survives the loop wedging is worse again: it reports health that does not exist. - What happens between SIGTERM and SIGKILL? That interval is where open orders are either cancelled or abandoned at the broker.
The engine here is not a wrapper around systemctl. It sends the sd_notify
datagrams a supervised bot owes systemd, and it audits unit-file text — which
is the part where the expensive mistakes are silent.
When NOT to Use
- You want to know what systemd actually loaded. This audits the text you
hand it. Drop-ins under
/etc/systemd/system/<unit>.d/override the file on disk and are invisible here, as are directives your systemd build is too old to recognise. Feed itsystemctl cat <unit>output, and treatsystemd-analyze verifyas the complementary check that the running manager agrees. - You are not on systemd. Docker/Kubernetes restart policies, supervisord,
runit and launchd share none of this vocabulary. The ideas transfer — a
liveness probe that proves the trading loop is alive, a bounded restart
budget, a drain window before SIGKILL — but nothing in
scripts/applies. - You want supervision to be your risk control. A watchdog restart is a
blunt instrument: it terminates the process, it does not flatten a position or
cancel resting orders. Order-level protection belongs to
kill-switch-and-drawdown-circuit-breakersandexecution-algorithm-kill-switch-integration; this skill keeps the process that runs them alive, and gets out of the way cleanly when it should not be. - Your bot is stateless and idempotent on restart. Then most of this is
ceremony. The care here is warranted because a restarted trading bot wakes up
with positions it did not open and orders it does not remember placing —
see
order-placement-idempotency. - You need alerting.
StartLimitAction=defaults tonone, so a unit that exhausts its restart budget sits infailedsilently until someone looks. WireOnFailure=to a notifier; this skill will not tell you the bot is down.
Prerequisites
- Linux with systemd and cgroup v2 for the memory directives — systemd.resource-control(5) states these settings "control the memory controller in the unified hierarchy".
- Root (or a user manager) to install the unit and run
systemctl daemon-reload. $NOTIFY_SOCKETin the bot's environment, which systemd exports forType=notify. Its absence is not an error: sd_notify(3) specifies that when$NOTIFY_SOCKETis unset "no status message could be sent, 0 is returned", and every notify call inscripts/supervision_helper.pyreturnsFalserather than raising, so the same code runs under a debugger.- A measured worst-case order-unwind time.
TimeoutStopSec=is a number you should be able to defend; the default is not. - Python 3.10+. Standard library only — no dependencies.
Workflow
-
Put the start rate limit in
[Unit], and check the arithmetic.StartLimitIntervalSec=andStartLimitBurst=are[Unit]options. systemd's directive table keepsService.StartLimitInterval,Service.StartLimitBurstandService.StartLimitActiononly "for compatibility, they moved into Unit" — and it has noService.StartLimitIntervalSecat all. Writing the current spelling under[Service]is an unknown key: ignored, leaving the unit onDefaultStartLimitIntervalSec, which is 10s.- Then check that the limit is reachable. Restarts are spaced by
RestartSec=, soStartLimitBurstattempts span at leastRestartSec × (StartLimitBurst − 1). If that is not shorter than the window, the limiter can never trip.RestartSec=10withburst=5needs more than 40s of window — and gets 10s if the interval landed in the wrong section. The engine reports this asSTART_LIMIT_UNREACHABLE. - Decide what happens when it does trip: the unit stays
faileduntilsystemctl reset-failed. That is correct, and silent. AddOnFailure=.
-
Make the watchdog reachable before you make it strict.
NotifyAccess="Takes one of none (the default), main, exec or all", and withnone"all status update messages are ignored".Type=notify(ornotify-reload) rescues this: "IfNotifyAccess=is missing or set to none, it will be forcibly set to main." Any other type plusWatchdogSec=and no explicitNotifyAccess=means every ping is discarded and systemd terminates the bot once per interval. Reported asWATCHDOG_PINGS_IGNORED, and it isCRITICALbecause the unit looks completely ordinary.
-
Run the pre-market gate in
ExecStartPre=, and let it fail only on faults.run_premarket_healthcheck()checks credentials, broker reachability and the exchange calendar. It fails closed: a calendar lookup that raises is a fault, not an implicit "market open".- Ask the calendar about the exchange's date, not the host's. Pass
as_of_dateorexchange_timezone. A UTC host asking an IST or US/Eastern calendar near midnight silently answers the wrong day, in both directions. - Give
broker_connectivity_fnits own timeout. A hanging probe burns the wholeTimeoutStartSec=budget before the unit fails.
-
Send
READY=1last, after the broker session is authenticated.- systemd holds the unit in
activatinguntil it arrives, and holds everything orderedAfter=this unit with it. Check the return value: a bot that believes it announced readiness but did not is killed atTimeoutStartSec.
- systemd holds the unit in
-
Ping from the trading loop, on the cadence systemd gave you.
- Read the interval, do not hard-code it:
watchdog_ping_interval_seconds()derives it from$WATCHDOG_USEC, which sd_watchdog_enabled(3) recommends pinging at "every half of the time returned here". Hard-coding 15s means an operator who lowersWatchdogSec=to 10 in a drop-in has scheduled the bot's execution. - Use
notify_watchdog_if_progressing(last_progress_monotonic, max_stall_seconds). The loop stampstime.monotonic()each iteration; the pinger refuses to vouch for a stale stamp. Monotonic, not wall-clock, so an NTP step cannot fake progress.
- Read the interval, do not hard-code it:
-
On SIGTERM:
STOPPING=1first, then unwind, then extend if you must.- Send
STOPPING=1as shutdown begins, then cancel resting orders. If cancellation is still progressing asTimeoutStopSec=approaches, sendEXTEND_TIMEOUT_USEC=— each message buys another window. The alternative is SIGKILL mid-unwind with live orders at the broker.
- Send
-
Handle a market holiday as a clean exit, not a failed unit.
HealthCheckResult.is_faultis false on a holiday even thoughpassedis false. Exiting non-zero fromExecStartPre=on a holiday marks the unitfailedand spends a start-limit slot for a day when nothing is wrong.- Do the holiday check in the main process: send
READY=1, see the calendar is closed, exit 0.Restart=on-failurerespects a clean exit and the unit goesinactive. Do not reach forSuccessExitStatus=on theExecStartPre— it is documented for "the main service process".
-
Audit the effective unit, not the file you wrote.
validate_unit_file_content(systemctl_cat_output)returns findings ordered most-severe-first. Branch onfinding.code; wording may change between versions.
Full step-by-step procedure and the shutdown sequence: see
references/workflows.md. Directive-by-directive sources: seereferences/standards.md. Printable sign-off checklist: seeassets/checklist.md.
Common Pitfalls
StartLimitIntervalSec=under[Service]. systemd has no such key in that section. It is ignored, the window collapses to the 10s default, and with a multi-secondRestartSec=the burst limit becomes unreachable — so the unit crash-loops against a broker outage exactly as if you had never configured a limit. This is the failure a rate limit is supposed to prevent, produced by a rate limit that looks configured. It shipped in this skill's own reference unit until v2.0.0.- A start limit that is arithmetically unreachable.
RestartSec=30withStartLimitBurst=5andStartLimitIntervalSec=60never trips: five attempts need at least 120s. Present, parsed, honoured, useless. WatchdogSec=on a non-notify type.NotifyAccess=defaults tonone, everyWATCHDOG=1is discarded, and the bot is killed once per interval. The journal shows a watchdog timeout on a bot that was pinging correctly the whole time.- Moving the ping to its own thread to "fix" missed deadlines. That thread stays healthy precisely when the trading loop wedges, which converts the watchdog from a liveness check on the strategy into a liveness check on the pinger. Gate the ping on loop progress instead, and put a timeout on the blocking call that caused the miss.
- Hard-coding the ping interval. 15s is right for
WatchdogSec=30and fatal forWatchdogSec=10. Read$WATCHDOG_USEC. READY=1before the broker session authenticates. systemd marks a broken service healthy and releases every unit ordered after it.- Interpolating broker text into
STATUS=. sd_notify(3) passes "a single-line UTF-8 status string"; a newline in an error message appends a real protocol field to the datagram. The wrappers here sanitise, andbuild_notify_message()refuses outright. Restart=alwayson a bot that can decide to stop. A clean exit for a holiday, a kill switch or a decommission is undone immediately.on-failurestill covers non-zero exits, fatal signals and watchdog timeouts.- Treating a market holiday as a start failure. It burns a restart slot,
leaves the unit
failed, and pages someone for a closed exchange. - A
TimeoutStopSec=shorter than a real unwind. When it expires the process "will be forcibly terminated by SIGKILL" — mid-cancellation, with orders still live. Measure it, and sendEXTEND_TIMEOUT_USEC=. - Auditing the file instead of the effective unit. A drop-in adding
Restart=alwaysis invisible to a check that reads/etc/systemd/system/ trading-bot.service. - Assuming the start limit failure is loud.
StartLimitAction=defaults tonone. The bot is down, the unit isfailed, and nothing has told anyone.
Verification
- Run the unit suite:
python -m unittest discover -s skills/systemd-supervision-for-trading-bots/scripts— all tests must pass. It needs no systemd, no root and noAF_UNIX. - Audit the shipped
scripts/trading-bot.serviceand confirmis_validis True with no findings. - Move
StartLimitIntervalSec=from[Unit]to[Service]and confirm bothSTART_LIMIT_IGNORED_IN_SERVICE_SECTIONand, once the window collapses to the 10s default,START_LIMIT_UNREACHABLEare reported. - Change
Type=notifytoType=simpleand confirmWATCHDOG_PINGS_IGNOREDfires atCRITICAL; addNotifyAccess=mainand confirm it clears. - Call
notify_stopping("a\nMAINPID=1")against a recording transport and confirm the payload still has exactly two lines. - Construct with
env={"WATCHDOG_USEC": "10000000"}and confirmwatchdog_ping_interval_seconds()returns 5.0, not 15. - Call
notify_watchdog_if_progressing(t, 20.0, now=t+20.001)and confirm no ping is sent — a stalled loop must not be vouched for. - Run the healthcheck with
is_holiday_fnreturning True and confirmpassed is Falsewhileis_fault is False. - On a real host:
systemd-analyze verify ./trading-bot.service, thensystemctl cat trading-bot.serviceand audit that. Confirmsystemctl show -p StartLimitIntervalSec,StartLimitBurst trading-bot.servicereports the values you intended — this is the check that catches the wrong-section defect on the running manager.
Related Skills
graceful-shutdown-draining-in-flight-tickskill-switch-and-drawdown-circuit-breakersexecution-algorithm-kill-switch-integrationorder-placement-idempotencyglobal-exchange-holiday-calendar-handlinginfrastructure-as-code-for-trading-hostsimmutable-infrastructure-for-trading-botscentralized-secrets-management-vault-integrationlog-aggregation-and-centralized-observabilityon-call-rotation-and-escalation-for-trading-systemsstrategy-decommissioning-and-position-unwind-procedure