Skip to main content

deadman

Installs as deadman-kit (pip install deadman-kit), imports as deadman — the PyPI name deadman was 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/verifier callables). 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. BrokerPort guarantees G1–G9 are the adapter's job; if fetch_order invents "closed" for an unmappable state, or fetch_order_by_client_id returns None without being authoritative, deadman will believe it. The conformance tests (tests/fake_broker.py is 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_available is 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

deadman_kit-0.1.0.tar.gz (63.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

deadman_kit-0.1.0-py3-none-any.whl (43.3 kB view details)

Uploaded Python 3

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

Hashes for deadman_kit-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e00e7a35dd5e16422106252a04ec1f7dc9c6790bce7dd4f5a9b25c2f7d241a74
MD5 299979dd53b5713a22f699cf142c1a31
BLAKE2b-256 5501462148416a20d604d6909eb7db1e5462c4ea8245d437f03790564a241977

See more details on using hashes here.

Provenance

The following attestation bundles were made for deadman_kit-0.1.0.tar.gz:

Publisher: release.yml on Roberto9210/deadman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for deadman_kit-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 56cd09f5b79c746beffbd8d479392eff233267e5c73feaee751b8148083bc9ea
MD5 ec88b77f2beaf0f76fe9615704156f02
BLAKE2b-256 822dc5c017005ea1d687311bdc3d4d07e52bb04e18f3283b3a854d7235fb7d4a

See more details on using hashes here.

Provenance

The following attestation bundles were made for deadman_kit-0.1.0-py3-none-any.whl:

Publisher: release.yml on Roberto9210/deadman

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.3.0

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page