deadman
Installs as
deadman-kit(pip install deadman-kit), imports asdeadman— the PyPI namedeadmanwas taken.
deadman sits between your strategy and your broker, and when it meets the unknown it stops instead of guessing.
Execution-safety primitives for automated trading systems. Zero runtime dependencies. Broker-agnostic, strategy-agnostic. Every claim below has a test or a spec section behind it — the links are the argument.
Specification: docs/SPEC.md (v0.1, closed 2026-08-18; written before the code).
Conformance statement, exact: 11 of 13 test groups implemented, 165 collected cases (164 pass, 1 platform skip
with its reason in the test), 2 elements declared out of scope with rationale — see SPEC §6b.
Not "13/13".
What it is not — said first, without shame
deadman does not bring a strategy, signals, a market-data feed, a broker connection, position sizing,
paper accounting, or any notion of your account's equity. You pass it a BrokerPort adapter (five
methods, guarantees G1–G9) and an Intent; it returns allowed/denied with a
code, and — if asked — runs the post-fill sequence honestly. Everything it needs to decide it receives in
the call. Nothing it needs is guessed.
Threat model, in plain words
- The external anchor is the guarantee: the ledger tip
(seq, hash)is published to a third party the operator does not control. Everything before the latest anchor is dated by that third party and provably unchanged. - The local hash chain is the mechanism: it detects corruption, partial writes, buggy rewrites, deletions, reordering, broken rotation — and it is what lets 64 bytes cover the whole history.
- Signing is optional and the key is yours (
signer/verifiercallables). With the key on the same disk as the ledger, a signature adds nothing over the chain; the library does not pretend otherwise.
The two tests that show the library's own limit, next to each other:
test_full_rewrite_with_recompute_passes_the_chain_alone — an attacker with
disk access rewrites an entry, recomputes the chain to the tip and replaces the tip file: the chain
verifies — and
test_same_rewrite_is_caught_by_an_external_anchor — the same rewrite, plus the
attacker wiping the local anchors file, is caught by verify(anchors=…) with the anchors held by the third
party (ANCHOR_MISMATCH).
What counts as a third party (SPEC §2b): a git branch protected against
force-push and deletion for everyone including the owner, or an RFC 3161 timestamp authority, or a
third-party append-only service with server-side timestamps. A remote you can force-push is not one — the
anchor is then worth nothing over the local chain. Sustained publisher failure is not silent:
ANCHOR_STALE in the ledger + a visible anchor_stale.flag, recovered by the next success
(test test_sustained_anchor_failure_raises_stale_flag_and_recovers).
An example publisher (git push to a protected branch) is in examples/ — it is your code; the
library never touches the network.
The primitives, each with the bug that motivated it
These are patterns from a real system, kept as patterns. They are the reason the library exists.
| Primitive | The bug | What deadman does instead | Proof |
|---|---|---|---|
| KillSwitch | The stop sentinel depended on a service that had been dead for months. | The mere existence of one file stops entries and exits; the file is never opened or parsed (a parse is one more failure mode); any error while checking also stops. | G1 — incl. a spy proving open() is never called on the sentinel |
| EntryHalt | An unknown order state in one cycle was forgotten in the next. | Persistent on disk, blocks new exposure only, never a close; unreadable file = halted; cleared by a reconcile that sees an empty book or by a human. | G2, G13 |
Exit predicate (spot_long_only_is_exit, net_position_is_exit) |
Daily limits, "eligibility" and a disabled policy trapped open positions. And the February stop-loss was handling August exits: exit thresholds came from a parameter bank frozen months earlier. | The asymmetry is a policy over an injectable predicate; only the kill switch and order sanity may stop an exit. The default is declared spot long-only; futures/shorts must pass a net-position predicate. | G3, G4, G9 |
| Intent / resolve_units | A quantity travelled in a bare amount with no unit; "sell the whole position" sold a USD figure at a stale price. |
units ∈ {USD, BASE, CONTRACTS} is mandatory; nothing is inferred; every failure names the missing datum and carries the intent. |
G3 |
| DailyLimits | A capital key that did not exist in the config was read with default 100 → the per-trade risk cap was a fixed $2 for months. And a paper run reported +$0.29 gross as "the result" while the net of fees was negative. | A missing key denies naming the key — never a default. P&L is net of fees; an unknown fee never counts as zero (worst case, or the day is marked unverified and entries stop). Rollover only via the injected clock, ledgered; a clock going backwards is fail-closed. Unreadable stats block entries only — exits are evaluated before the file is read. | G4 test_g4_7_*, test_g4_4_*, test_g4_6_*, test_g4_9_* |
| OrderSanity | A feed-freshness check read a key no producer ever wrote, so it always said NOMINAL. And equity = max(equity, 1.0) turned "I don't know how much money there is" into "order too small". |
Only inputs the caller passes in the call; any None/NaN denies as <ARG>_MISSING. quantize() floors to the venue step, entries and exits alike; an order below the venue minimum is denied, never enlarged. |
G5 test_g5_5_below_min_notional_is_denied_not_enlarged, test_g10_* |
| Ledger | Records could be edited, and part of the history had been summarised with later data. A rotation left a segment that no longer chained to genesis. | Hash chain + atomic writes + OS lock; anchored rotation (LEDGER_ROTATED carries the previous file's last hash and sha256); verify() crosses segments and never says plain OK when one is missing. Zero deps. |
G11 — incl. two real processes appending |
| HonestExecutor | The adapter declared success on send, not on fill; a timeout counted as a trade; an order stayed alive with no owner after a timeout. | Write-ahead intent with a deterministic client order id before the network; timeout ⇒ the order is presumed alive and resolved by client id — never re-sent; partial is partial, duplicate fills counted once and noted; anything outside the state machine ⇒ UNKNOWN_STATE + halt; startup() reconciles before any intent is accepted. |
G6/G7, G9 — incl. a real process killed mid-send |
| Injectable clock | A now() nobody controlled made a daily rollover and an outcome window irreproducible. |
Every primitive receives a Clock; no module calls the wall clock (static test). |
G12 |
| Writer seal | Two adapters once ran at the same time against the same state. | Every state file carries (writer_pid, writer_started_at, write_seq); a changed seal between read and write is CONCURRENT_WRITER_DETECTED — not prevented, made loud. |
G13 |
The principle behind all of it — zero plausible defaults — is a contract, not a slogan: SPEC §2.
What this library does not protect against
This section is what makes the rest credible.
- A deliberate rewrite of the ledger by someone with disk access, when no external anchor covers it.
The chain alone verifies after a recompute — proven, not hidden:
test_full_rewrite_with_recompute_passes_the_chain_alone. Only an anchor held by a real third party catches it (test_same_rewrite_is_caught_by_an_external_anchor), and only for history before that anchor. - Anything after the latest anchor. The window equals your anchoring interval; that is why anchors are
forced after halts, unknowns and kill events (
test_anchor_forced_after_safety_events_and_by_count). - A remote you can force-push. It is not a third party; see above.
- Two writers racing on a state file. Not prevented (no OS lock on halt/stats in 0.1) — detected and escalated to a halt (G13).
- A broker adapter that lies.
BrokerPortguarantees G1–G9 are the adapter's job; iffetch_orderinvents "closed" for an unmappable state, orfetch_order_by_client_idreturnsNonewithout being authoritative, deadman will believe it. The conformance tests (tests/fake_broker.pyis the reference shape) are how you check an adapter. - Your account and your sizing. deadman has no equity, no positions of its own, no snapshot of your
account.
size_availableis whatever you pass; a stale balance you pass as fresh is your stale balance (SPEC G8 is out of scope for this reason — SPEC §6b). - Losing money. It stops you from acting on what it cannot vouch for. It does not know whether your strategy has an edge.
Quickstart (honest: this is the whole flow)
from deadman import (Paths, SystemClock, WriterIdentity, Ledger, KillSwitch, EntryHalt,
DailyLimits, Limits, OrderSanity, HonestExecutor, Intent, spot_long_only_is_exit)
clock = SystemClock()
paths = Paths("/var/lib/mybot/deadman") # one explicit root for every state file
ident = WriterIdentity(clock)
ledger = Ledger(paths, clock, publisher=my_publisher) # my_publisher: see examples/git_anchor_publisher.py
kill = KillSwitch(paths, ledger) # `touch /var/lib/mybot/deadman/kill_switch.enabled` stops everything
halt = EntryHalt(paths, clock, ident, ledger)
halt.startup_check() # another live process owns the halt file? -> loud
limits = DailyLimits(paths, Limits(max_trades_per_day=20, max_daily_loss_usd=50.0, worst_case_fee_bps=80.0),
spot_long_only_is_exit, clock, ident, ledger)
sanity = OrderSanity(allowed_symbols=frozenset({"BTC/USD"}), max_latency_ms=500, max_spread_bps=20)
ex = HonestExecutor(my_broker_port, kill, halt, limits, sanity, ledger, spot_long_only_is_exit, clock,
fill_timeout_s=10.0, poll_interval_s=1.0)
report = ex.startup(["BTC/USD"], position_of=lambda sym: None) # reconcile BEFORE any intent; halts if it finds anything
intent = Intent(symbol="BTC/USD", side="buy", units="USD", amount=25.0, kind="ENTRY", client_id="sig-2026-08-18-001")
result = ex.execute(intent, price=64_000.0, broker_status="connected", latency_ms=42.0,
bid=63_995.0, ask=64_005.0, size_available=1_000.0)
print(result.status, result.code, result.reason) # FILLED | PARTIAL | NO_FILL_CANCELED | DENIED | UNKNOWN
print(ledger.verify()) # the ledger alone explains every final state
my_broker_port is your adapter implementing BrokerPort (five methods). my_publisher is your anchor
publisher. Neither is provided: the library does not talk to the network.
Install and test
pip install deadman-kit # installs as deadman-kit, imports as deadman; zero runtime dependencies
python -m pytest -q tests # 165 cases; Windows, Linux, macOS in CI
CI: .github/workflows/deadman.yml — ubuntu/windows/macos × Python 3.10/3.12/3.14, plus a job that builds
the wheel, installs it into a clean venv with --no-deps and runs a smoke flow. Windows is not optional there:
the msvcrt.LK_NBLCK finding (LK_LOCK gives up with an OSError that is not a PermissionError) is a
claim only a Windows run keeps honest.
License
MIT. See LICENSE. Changes: CHANGELOG.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file deadman_kit-0.1.0.tar.gz.
File metadata
- Download URL: deadman_kit-0.1.0.tar.gz
- Upload date:
- Size: 63.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e00e7a35dd5e16422106252a04ec1f7dc9c6790bce7dd4f5a9b25c2f7d241a74
|
|
| MD5 |
299979dd53b5713a22f699cf142c1a31
|
|
| BLAKE2b-256 |
5501462148416a20d604d6909eb7db1e5462c4ea8245d437f03790564a241977
|
Provenance
The following attestation bundles were made for deadman_kit-0.1.0.tar.gz:
Publisher:
release.yml on Roberto9210/deadman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deadman_kit-0.1.0.tar.gz -
Subject digest:
e00e7a35dd5e16422106252a04ec1f7dc9c6790bce7dd4f5a9b25c2f7d241a74 - Sigstore transparency entry: 2507516605
- Sigstore integration time:
-
Permalink:
Roberto9210/deadman@6f119b270bc3eac17a9116179c8740f9e65c737f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Roberto9210
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6f119b270bc3eac17a9116179c8740f9e65c737f -
Trigger Event:
push
-
Statement type:
File details
Details for the file deadman_kit-0.1.0-py3-none-any.whl.
File metadata
- Download URL: deadman_kit-0.1.0-py3-none-any.whl
- Upload date:
- Size: 43.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
56cd09f5b79c746beffbd8d479392eff233267e5c73feaee751b8148083bc9ea
|
|
| MD5 |
ec88b77f2beaf0f76fe9615704156f02
|
|
| BLAKE2b-256 |
822dc5c017005ea1d687311bdc3d4d07e52bb04e18f3283b3a854d7235fb7d4a
|
Provenance
The following attestation bundles were made for deadman_kit-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Roberto9210/deadman
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deadman_kit-0.1.0-py3-none-any.whl -
Subject digest:
56cd09f5b79c746beffbd8d479392eff233267e5c73feaee751b8148083bc9ea - Sigstore transparency entry: 2507517104
- Sigstore integration time:
-
Permalink:
Roberto9210/deadman@6f119b270bc3eac17a9116179c8740f9e65c737f -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Roberto9210
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6f119b270bc3eac17a9116179c8740f9e65c737f -
Trigger Event:
push
-
Statement type: