# Agent Setup Guide — go-trader

Repository: `https://github.com/richkuo/go-trader.git`

Operator runbook for agents that install, configure, run, update, and operate go-trader. Reader-facing overview: [README.md](README.md). Coding constraints and PR conventions: [CLAUDE.md](CLAUDE.md) and [AGENTS.md](AGENTS.md).

Quick flow for a new server: tell OpenClaw `install https://github.com/richkuo/go-trader and init`.

---

## Core Rules

- Run git from the repo root.
- Use `/opt/homebrew/bin/go` (macOS) or `/usr/local/go/bin/go` (Linux) if `go` is not on PATH.
- Use `uv run --no-sync python` for dev, backtest, and manual CLI work. The scheduler calls `.venv/bin/python3` directly, so no PATH configuration is needed for the service.
- Install Python dependencies with `uv sync --no-dev` on a service host and `uv sync` in a development checkout or worktree.
- Scheduler config: `scheduler/config.json` (start from `scheduler/config.example.json`). On deployments the real file lives outside the deploy tree at `/var/lib/go-trader[/<instance>]/config.json`.
- State is SQLite only: default `scheduler/state.db`. Optional root `paper_db_file` moves the paper scope into a second file (§ Storage Ownership).
- Never store secrets in config files. Put Discord and exchange credentials in systemd environment variables (a Docker deployment uses `docker/go-trader.env`, § Docker Container).
- Prefer `./go-trader init` for humans, `./go-trader init --json … --output scheduler/config.json` for agents and scripts.
- TradingView export: ask which strategy IDs (or all) before running.
- **CRITICAL: always update a host deployment with `scripts/update.sh`. Never run `git pull` + `go build` by hand.** The Go binary and the Python check scripts share an argv contract, so a build at a different commit than the scripts is an asymmetric deploy. A Docker deployment updates only by pulling a release image, which holds both from one commit (§ Docker Container).

---

## Prerequisites

```bash
python3 --version
uv --version 2>/dev/null || /usr/local/bin/uv --version 2>/dev/null || /opt/homebrew/bin/uv --version 2>/dev/null || echo "NOT_INSTALLED"
go version 2>/dev/null || /usr/local/go/bin/go version 2>/dev/null || /opt/homebrew/bin/go version 2>/dev/null || echo "NOT_INSTALLED"
git --version
rsync --version
```

Requirements: Python 3.12+, `uv`, Go 1.26.2, Git, `rsync`.

```bash
# Linux: system-wide, so root and the service account both find them
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh
curl -sL https://go.dev/dl/go1.26.2.linux-amd64.tar.gz | sudo tar -C /usr/local -xzf -
# macOS
brew install uv go@1.26
```

Install `uv` and Go where every account can run them. `scripts/update.sh` looks for `uv` on `PATH`, then at `/usr/local/bin/uv`, `/usr/bin/uv` and `/opt/homebrew/bin/uv`, and for Go on `PATH`, then at `/opt/homebrew/bin/go` and `/usr/local/go/bin/go`. It never looks in a home directory. The per-user installer puts `uv` in `~/.local/bin`, which only that account can use: a copy in `/root/.local/bin` is not visible under `sudo`, and the `go-trader` service account cannot run it. `scripts/shared-feed-convert.sh plan` and `scripts/migrate-service-layout.py plan` check that both tools run as root and as the service account, and refuse with this fix before any build.

---

## Install

```bash
git clone https://github.com/richkuo/go-trader.git
cd go-trader
uv sync --no-dev
```

This installs a service host. In a development checkout or worktree, run `uv sync`, which also installs the test tools (`pytest`, `pytest-mock`, `pytest-xdist`) from the `dev` dependency group.

If the repo already exists, ask whether to reconfigure, update, or do a fresh install before changing it.

Build:

```bash
VER=$(git describe --tags --always --dirty 2>/dev/null || echo dev)
/opt/homebrew/bin/go -C scheduler build -ldflags "-X main.Version=$VER" -o ../go-trader .
./go-trader --help
```

The `Version` ldflag appears in Discord summary titles; without it the binary reports `dev`. After the initial install, use `scripts/update.sh` for every rebuild.

---

## Configure

```bash
./go-trader init                                                    # human flow
./go-trader init --json '{"assets":["BTC","ETH"],"enableSpot":true,"spotStrategies":["momentum","rsi"],"spotCapital":1000,"spotDrawdown":60}' --output scheduler/config.json
```

The wizard covers assets, strategy groups, paper/live mode, per-strategy capital, live risk settings, Discord channels, and auto-update mode. It prompts before overwriting.

Config skeleton and the full field list: [README.md](README.md) § Configuration Reference. Rules that govern a hand-written config:

- Strategy entries need `id`, `type`, `script`, `args`, `capital`, `max_drawdown_pct`, `interval_seconds`.
- `open_strategy` and `close_strategy` are objects of shape `{"name": "<id>", "params": {…}}`. Per-evaluator params live on the close ref, never on the strategy. A legacy `close_strategies` array of length ≤1 is still read; length >1 is rejected at load.
- Tier lists use `tp_tiers`; each tier is `{"atr_multiple"|"profit_pct": N, "close_fraction": 0..1, "sl_after"?: {…}}`. Per-tier legacy `atr` / `multiple` / `fraction` aliases still parse. Write the canonical names.
- `discord.channels` / `telegram.channels` keys: `spot`, `options`, `hyperliquid`, `topstep`, `robinhood`, `okx`, `luno`, plus optional paper keys such as `okx-paper`. A non-empty `<platform>-paper` key also splits cycle summaries, leaderboards and Sharpe groups by mode; with no such key the grouping stays merged.
- `summary_frequency` uses the same key scheme. Values: `hourly`, `daily`, `every`, `per_check`, `always`, or a Go duration (`30m`, `2h`). The wall-clock cadence persists in SQLite and survives restart and SIGHUP. `options`, `perps`, `futures`, and `manual` post every channel run; `spot` posts hourly. A trade always forces an immediate post.
- `discord.owner_id` comes from `DISCORD_OWNER_ID`; it enables DM upgrade and migration prompts.
- **Separate live and paper state files.** Set root `paper_db_file` beside `db_file` and the paper scope's books, risk row, kill-switch events and correlation snapshot move to that file; the live scope, process metadata, the live-only wallet and cash-flow tables and shared regime history stay in `db_file`. Omit `paper_db_file` and the single-file layout is unchanged. The two paths must resolve to different physical files — relative paths, symbolic links and hard links are all checked — or startup refuses with exit code 80.
- **Stored identity.** Optional per-strategy `storage_strategy_id` names the row a strategy owns inside its file; it defaults to `id`. Storage identity is `(scope, storage_strategy_id)` and must be unique **within one file**; the same value in the live and paper files is the supported alias. Rename a strategy's `id` and set `storage_strategy_id` to the old name to keep its cash, positions, latches, queued actions and history. Both keys are restart-required.

Live-mode risk defaults offered by `init`: per-strategy spot drawdown 5%, per-strategy options drawdown 10%, portfolio kill switch 25%, portfolio warn threshold 60% of the kill switch.

**Unified per-regime close block** (`tiered_tp_atr_regime` / `tiered_tp_atr_live_regime` / `tiered_tp_atr_live_regime_dynamic`): instead of a tier-keyed list, the close ref carries a top-level `trend_regime` where each label owns its stop loss and tier ladder:

```json
{"name": "tiered_tp_atr_live_regime", "params": {"trend_regime": {
  "trending_up": {"stop_loss_atr": 1.5, "tp_tiers": [
    {"atr_multiple": 2.0, "close_fraction": 0.5, "sl_after": {"kind": "trail_from_here", "tp_atr_fraction": 0.5}},
    {"atr_multiple": 4.0, "close_fraction": 1.0}]},
  "ranging": {"stop_loss_atr": 0.8, "tp_tiers": [
    {"atr_multiple": 1.0, "close_fraction": 1.0}]}
}}}
```

All regime labels must be present (exhaustive, no fallback). Tier counts may differ per label. The block **owns the stop loss** through per-regime `stop_loss_atr`, so declaring any strategy-level stop field alongside it is rejected at load. The whole block is hot-reload-gated as a unit: change it while a position is open and the reload is refused. Flatten first.

**Dynamic variant** (`tiered_tp_atr_live_regime_dynamic`, HL perps and `type=manual` only): the same block, plus an optional top-level `regime_confirm_cycles`. It is the number of consecutive cycles a new ATR-regime label must hold before the position applies it. On a live position, the protection sync then re-places the stop-loss and take-profit orders for the new label. A paper position moves its stop as **Paper stops** describes. The default is `2`. The value must be a whole JSON number >= 1, and `1` applies a change on the first cycle that shows it. A string, a boolean, `null`, `0`, a negative value, a fraction, or a value past the integer range fails to load. The key is part of the hot-reload-gated block. It is an unknown param on the other two unified closes. The backtester rejects this close.

**Trailing-ratchet close** (`trailing_tp_ratchet` / `trailing_tp_ratchet_regime`): a trailing-ATR stop where each cleared take-profit tier tightens the trail and optionally scales out.
The scalar form needs a positive strategy-level `trailing_stop_atr_mult` (the initial loose trail, and the sole stop owner).
The regime form needs `trailing_stop_atr_mult_regime` instead.
`tp_tiers` is a list (scalar form) or `{label: [tiers]}` (regime form, frozen at open).
Each tier is `{atr_multiple, close_fraction?, trailing_mult_after | tp_atr_fraction}`: `close_fraction` (default `0`, cumulative) scales out, `0` means trail-only; the trail tightens to `trailing_mult_after` (absolute ATR multiple) **or** `tp_atr_fraction × atr_multiple` (relative), never both, and never loosens.
The first rung must be ≤ the initial trail.
It places **no on-chain take-profit**: partial closes ride the close evaluator, the on-chain stop rides the trailing-stop walker.
Tier triggers use entry ATR.
Scope: Hyperliquid perps and `type=manual`.
Backtestable.
In live mode only (paper places no venue order and must match the backtester), the Hyperliquid check script rewrites a partial close whose lot-floored quantity is zero or whose value at the check-time price is below the venue minimum order value plus a 3% safety margin (the execute-side market close submits at 1% limit slippage and the price can drift before submission) to a noop (`close_gate: below_venue_minimum`); a full close is never rewritten (`current_close_fraction` snaps a final tier within `1e-9` of the whole remainder to exactly `1.0`), and the gate is skipped when the lot size cannot be resolved (adapter meta, else the `/tmp/hl_meta.json` cache under `market_feed=websocket`, never a fetch).
Paper Hyperliquid perps (#1716) book synthetic entries, flips, scale-in adds and partial closes (signal partials and paper take-profit tier fills) at venue lot sizes (`scheduler/hyperliquid_paper_lot.go`).
The scheduler reads `universe[].szDecimals` for the exact coin from the public `meta` request through `hlPostInfoTo` (`scheduler/hyperliquid_lot_metadata.go`), at the endpoint the adapter uses (`HYPERLIQUID_TESTNET=1` selects testnet).
The refresh runs outside `mu`, before the execute lock (and in the paper no-signal upkeep of a strategy with `sl_after` rules), and only one refresh per endpoint runs at a time.
It is charged to the websocket feed ledger when one exists, else to a local enforced ledger of 4 requests a minute, with reason `lot_metadata`.
A snapshot is refreshed after 1 hour and expires after 6 hours; a failed refresh retries after 30 seconds, doubling up to 10 minutes; a response above 8 MiB is refused.
A valid `szDecimals` of 0 is a lot size; an absent, null, non-integer, negative, above-12 or duplicated entry is refused for that coin.
Each quantity is floored the way `adapter.floor_lot_size` floors it (`hlFloorLotSize`: the shortest decimal string plus 1e-9 lot of slack, Python's 28-digit decimal context).
An entry or add holds when its floored quantity is 0 or its floored value at the booked price is below $10.30 ($10 plus the 3% margin).
A partial close holds on the same test at the decision price (the cycle mid), and a value equal to $10.30 passes, as in the live gate; a tier fill still books at the tier price.
A held order writes nothing: no cash, fee, trade, risk result, tier consumption, position stamp or alert, and a held partial close never opens the other side.
When the lot size is unknown (no snapshot, expired, coin absent or entry refused) these orders hold, and one `[hl-lot]` line is logged per endpoint and coin until the lot size returns, which logs one `restored` line.
Full signal closes, full stops and paper kill-switch and circuit-breaker flattening still close the whole book, also for unrounded legacy positions and during an outage.
A flip books its full close and holds only the new open when that open fails the test, the same as an open refused for cash.
A cycle whose partial close holds (for example a sub-lot leftover that the close evaluator asks for again after a floored tier) runs the paper no-signal upkeep (`runPaperHLQuietCycleMaintenance`: dynamic-regime stop re-arm and breach, then the `sl_after` move), as live does when its close gate rewrites the same close to a noop.
A paper tier counts as cleared, for `sl_after` and for unified take-profit consumption, when the closed ratio reaches the threshold within `1e-9` or when the quantity still missing to the threshold floors to zero lots at the coin's lot size (`paperTierClearedByLot`); a close more than one lot short does not clear it, and replay-mirror strategies keep the exact ratio test.
Confirmed live fills and replay-mirror rows keep their booked quantities.
Remaining differences, pinned by `TestHLPaperLotParityWithLiveGateAndBacktester` (`-tags pyintegration`): live opens round to the nearest lot, the execution-spec backtester reserves the taker fee before it floors an entry and rejects scale-in, no path gates a full close, and the paper tier price can differ from the decision price.
A **full close on a shared coin** (two or more live HL perps strategies on the coin) is sent as a sized close to protect peer exposure.
Every sized execute-lane close (a partial close, a shared-coin full close, a legacy reverse-signal close with close fraction 0, a `type=manual` cycle close and an operator `manual-close`) takes its size and order type from `planHLCloseOrder` (`scheduler/hyperliquid_close_plan.go`).
The key is the strategy's side and book, the peers' books on the coin split by side (hedge legs and `type=manual` peers included, `hlPeerBooksOnCoin`) and the signed on-chain net `S`.
`target` is the strategy's remainder plus same-side peer books minus opposite-side peer books; the size is `min(close, distance from S to target in the closing direction)`; when that distance is zero or less nothing is sent and a throttled CRITICAL alert fires (the reconciler owns the book).
The order is `reduce_only` when `S` is on the strategy's side and `target` is zero or on that side, else `cross` (a normal IOC that moves the net onto the opposite-side peers' books), and `resolveHLCloseOrder` re-plans every sized close on a fresh `clearinghouseState` refetch taken just before the order, after every earlier strategy in the cycle has booked, so a peer that opened or closed earlier in the same cycle is seen (the cycle-start snapshot never caps or skips a close).
When that refetch fails, only a plan that is an uncapped `reduce_only` close at the book size is still sent (it cannot cross zero, and with the cycle-start account unreadable that is the plan when no opposite-side peer holds a book); a plan that would cap, skip or cross is deferred, with no order sent and no protection cancelled.
So the chain always ends between `S` and `target`, and a close never opens exposure past what the books state.
A full-intent sized close that fills short of the book (more than `0.0001` below it) books only the filled quantity (`HyperliquidResult.SizedCloseBookedQty`), keeps the remainder on the book with the protection ids that execute cancelled cleared, and re-arms the remainder's stop in the same cycle (`rearmAfterSizedClose`) in place of the post-trade protection sync; the whole book is deleted only when the fill covers it.
The `type=manual` cycle close books the same way through `bookManualCycleClose` inside `settleManualCycleClose`: on a short fill it clears the cancelled protection ids from the book (a cancel the venue reported as failed keeps its id), queues the close row, and re-arms the remainder's stop in the same cycle, because the book is reduced only when the next cycle drains the row.
A rejected close (execute lane or `type=manual` cycle) re-arms the whole book the same way when its outcome is unknown or a requested cancel succeeded (`hlUnfilledCloseNeedsRearm`); a close the script never sent (`order_outcome: not_sent`) cancelled nothing and re-arms nothing, and a rejected `type=manual` cycle close sends the throttled live-exec failure alert. **Stop and take-profit size (`hlOwnStopShare`, the only sizer):** every live Hyperliquid stop and take-profit, including the close re-arm, uses `Q` from `hlOwnStopShare`.
`Q` is at most the book and at most the own-side chain units.
On a drift, resting stops share the backed units first and unarmed books take what is left, so the side's stops cover the chain.
The close re-arm calls it with the remainder as the book and its own stop not resting; an empty peer list counts as one armed peer book, which keeps the #1577 decision table.
The Hyperliquid reconciler runs every cycle with a fetched account (its shared-coin pass covers every live book), so the share never counts a peer fill that is not booked yet.
A book alone on its coin is booked only by its own due reconcile, so its share CRITICAL fires only in a cycle where that reconcile ran (`hlCycleShare.markReconciled`); before that it logs one line.
A pre-loop resize pass then grows or shrinks a resting stop that is outside one lot of the lot-floored `Q`, and flags resting take-profit tiers above `Q`; the pre-loop re-arm pass (`runHyperliquidShareRearm`) runs the protection sync for each flagged strategy, so the tiers shrink in the same cycle.
A zero `Q` that only opposite-side books explain (no drift) keeps the resting stop and tiers: they are reduce-only and protect the book again when the netting book shrinks.
A zero `Q` from drift cancels them; a cancelled `type=manual` recorded stop sends one CRITICAL with its trigger, because no config owner can re-place it, when a peer is on the coin or its own reconcile ran this cycle; a book alone on its coin before its reconcile logs one line.
An unknown or stale account view places at the book and never shrinks or cancels.
A book above what the chain and the opposite-side books explain (drift) sends one CRITICAL per change of `Q`; a book that only an opposite-side book nets logs one line.
The re-arm pass also arms, once a fresh `Q` is above zero, a book with no stop and no recorded trigger: the trailing arm for a trail owner, the protection sync for an ATR or tiered owner.
The re-arm pass syncs in `hlProtectionGuardFullHoldRegime`, which plans from the applied regime label and never advances the dynamic-regime confirmation count (only due evaluations count).
Static-scalar owners are re-armed by the liquidation audit.
An immediate stop fill books the lot-floored size the script reports (`stop_loss_size`), never more than was sent.
The liquidation audit places and tightens at `Q`.
The sizer reads the account after the close first on every lane and outcome (`fresh`).
When that read fails, a known outcome uses the pre-send reading less the confirmed fill (`derived`), an unknown outcome uses the pre-send reading with no fill subtracted (`stale`, an upper bound), and no reading at all uses the remainder (`none`).
The pre-send reading is the refetch the plan used, else the cycle-start snapshot (the refetch-failed uncapped close and whole mode).
A close outcome is known only when `check_hyperliquid.py execute` reports `order_outcome` as `filled`, `rejected` or `not_sent` (`hlExecuteFillOutcome`); a missing field, `unknown` (the catch-all) or no JSON is unknown.
`Q` at zero on any basis but `none` places no stop.
Every arm consumes `Q` and never resizes it, and the re-arm calls `hlOwnStopShare`.
The re-arm covers every stop owner of the strategy: the protection sync for an ATR stop (in `hlProtectionGuardStopLegAfterFailedClose` mode, so the queued row keeps the take-profit ids, and with the take-profit tiers sized the same), the trailing arm for a trail or ratchet stop, the percentage arm, and, for a `type=manual` strategy with none of these owners (`manualRecordedStopOwner`, for example a stop set by `manual-open --stop-loss-pct`), the recorded pre-close trigger.
Each arm hands the old order id to the placement so it is verified on-chain first, and after a fill the protection sync force-replaces a pre-close stop whose cancel failed. **Unconfirmed pre-close orders:** the unconfirmed set is every order id the close asked to cancel that the venue did not confirm cancelled, which is the requested ids the book still holds after the confirmed ones are cleared (`hlCloseUnconfirmedSet`).
Every member is verified by id on the chain and then removed, resized or reported; nothing is cancelled blind.
With `Q > 0` the stop in the set is resized through the arm's verify-first cancel id, and after a fill or a resize each take-profit in the set is force-replaced at the new size (`ForceTPReplace`), or, when a queued row owns the tiers, cancelled verify-first through `cancel_tp_oids` so the next due sync re-places it after the drain.
With `Q = 0` nothing is placed and every member of the set is cancelled verify-first: the stop through the cancel-only mode of `check_hyperliquid.py --update-stop-loss` (`--size=0`: read the open orders, cancel the id when it is open, report an external fill, place nothing), the take-profits through the protection sync `cancel_tp_oids` (open means cancelled, not open means gone, filled is left to the reconciler, an unreadable open-order list is UNVERIFIED).
A removed id leaves the book like a confirmed cancel (the stop id and trigger are cleared, and a take-profit tier stays re-placeable), through the same book rule (`applyRearmedStopLossToBook`) for the CLI and the dashboard, which keeps a newer stop id the book gained before the write;
a failed or unverified id keeps its place in the book. **Alerts:** one CRITICAL owner alert per outcome (`formatCloseRearmReport`, sent by `notifyCloseRearm` to the channels and the owner DM, outside `mu`) that names the position and the next action: no stop because nothing backs the remainder;
a stop smaller than the remainder (it names the unbacked units);
a stop placed on a `derived`, `stale` or `none` basis (it names the basis and why the post-close read failed);
no trigger;
no arm owning the cancelled stop;
an unreadable open-order list;
a rejected cancel that leaves the pre-close stop resting at its pre-close size (a rejected force-replace cancel in the protection sync reports this through `cancel_stop_loss_error`);
a rejected placement;
a replacement that did not rest;
or an unknown outcome.
A stop update or protection sync that returns no result is reported as UNVERIFIED (a stop may rest untracked, or the pre-close stop may still rest; read the open orders before placing anything), never as no stop.
The `Q = 0` alert names each unconfirmed pre-close order as removed, already filled, STILL RESTING (with the manual cancel action: the Hyperliquid UI, or `go-trader manual-cancel-sl` for the stop of a `type=manual` strategy) or UNVERIFIED.
The same alert reports the take-profit leg as placed, kept, removed, failed or unknown, and a failed or unknown take-profit leg makes it CRITICAL; a tier whose placement the venue never resolved is reported unknown and never failed (even though the sync also lists an error for it), a sync with both an unknown and a failed tier names each state in that one alert, and the re-arm sends no separate unresolved-placement alert (the ordinary protection sync still sends it).
The arms' own alerts are suppressed on this path (the trailing update and the protection sync get no notifier).
The `SIZED CLOSE FILLED SHORT` alert is sent only after the re-arm result is known and states that result, CRITICAL when the result is; it never claims a stop that is not placed.
A re-armed stop that fills at once books only its own quantity, and a drained close row that empties the book closes the position, so the close is never booked twice.
Go passes `--size` plus `--close-mode=reduce_only|cross` (also in `executeProbeArgv`); `check_hyperliquid.py execute` floors the size with `adapter.floor_lot_size` (the only lot floor, shared with the venue close gate), reads the mid price (`sized_close_price`, mid plus or minus 1%) before any protection cancel, exits 1 with `order_outcome: not_sent` and cancels nothing when the size floors to zero or the mid cannot be read, and sends `market_close_sized` (`Exchange.order` IOC at that price with the strategy's own side).
Every execute payload carries `order_outcome`.
The sole-owner full close and the escalated `ForceFullClose` keep `--close-full-position` (`market_close(sz=None)`); opens and the direction=both flip keep `market_open`.
Venue behavior for a reduce-only IOC larger than the position is not confirmed by official docs (an unofficial source says the fill is clamped).
Because the cap uses the pre-send refetch, an oversize order is sent only when the chain shrinks after that read or the refetch failed and the uncapped book size went out.
A clamp gives a short fill, which the short-fill booking above records at the filled quantity without deleting the remainder.
A rejection is a failed execute: nothing is booked and the failed-close re-arm restores the stop.
When that remainder is worth less than the venue minimum plus the 3% margin, `applySharedCoinFullCloseFloor` (`scheduler/hyperliquid_shared_close_floor.go`) escalates to `market_close(sz=None)` only when every peer is provably flat: every peer's own virtual book on the coin is zero (the wallet reports one netted total per coin, so an opposite-side peer or a virtual-over-on-chain drift can make the net look like this strategy alone) AND the on-chain position (looked up by the raw venue coin name, e.g. `kPEPE`) is on the strategy's side and no larger than its virtual quantity, on BOTH the cycle-start snapshot AND a fresh `clearinghouseState` refetch taken immediately before the order (a peer that opened earlier in the same cycle is seen; a refetch failure defers).
It defers the close when the account state is not known, and otherwise sends ONE CRITICAL alert, rewrites the decision to `close_gate: shared_coin_stranded_remainder`, and persists `shared_close_hold_usd` plus `shared_close_hold_reason` (`peer_busy` or `venue_rejected`) on the position so the sized order is not resent.
The decision runs BEFORE the trailing-stop, fixed-ATR arm and protection-sync blocks, so a held or deferred close reaches them with `Signal == 0` and the stop keeps walking, arming and syncing exactly as a noop cycle would;
any live execute that was actually submitted to the venue AND asked it to cancel protection (`HyperliquidResult.LiveOrderSubmitted` and `LiveOrderCancelRequested`;
never a pre-submit skip such as `already long, skipping buy`, and never a partial close, which requests no cancel and leaves the resting stop untouched) and then failed after its cancel-then-close ordering removed (or may have removed) the on-chain stop, a `venue_rejected` rejection included, re-arms the stop in the same cycle, when its outcome is unknown or a requested cancel succeeded, through `rearmAfterSizedClose` (sized by `resolveHLCloseRemainderStop`, with one CRITICAL alert per outcome as described for the sized close above) into `rearmProtectionForCloseRemainder`, which covers all seven exclusive stop owners across three arms:
protection sync covers the fixed and regime ATR stop owners and the tiered-TP surface, `rearmScalarStopAfterFailedClose` covers the percentage owners `EffectiveStopLossPct` resolves (`stop_loss_pct`, `stop_loss_margin_pct` and the `max_drawdown_pct` fallback) by re-placing at `riskAnchorPrice() × EffectiveStopLossPct` through `hlLiquidationClampReplace`, the same liquidation-clamped update-script path the #1450 audit re-arms with, and only when `effectiveTrailingStopPct` resolves nothing so a trail owner is never double-armed, and `rearmTrailingStopAfterFailedClose` covers a trail-owned stop (`trailing_stop_atr_mult`, its regime form, and `trailing_tp_ratchet`) with the scale-in resize shape:
the persisted `StopLossHighWaterPx` anchors the trigger (never the mark), the pre-execute trigger is the floor the replacement may only tighten, the liquidation clamp applies, the arm places the size `resolveHLCloseRemainderStop` returns, and the pre-execute OID is handed to the update script as the cancel OID so it verifies the order on-chain first (an unreadable execute result therefore never clears the OID in the book;
a cancel that actually failed is cancelled there, and one that already landed places fresh).
A `peer_busy` hold still escalates once every peer is flat on-chain and in its book; a `venue_rejected` hold sends no order and no alert again, and a peer going flat does not resend it.
Both clear when the remainder is again above the gate or the escalated close fills.
Venue result for a whole-position reduce-only close below $10: the Hyperliquid API docs state only "Order must have minimum value of $10." with no exemption for reduce-only or full closes, and no testnet probe has been run from this repo, so the escalated close is treated as unconfirmed: a `minimum value` rejection of it strands the position with one alert and a `venue_rejected` hold instead of the throttled failure loop.
Because that wording is unconfirmed, the hold does not depend on it: any escalated whole-position close that fails stamps `escalate_failed` on the first failure (a transient failure is still retried the next cycle) and, when the next escalated attempt fails too, stamps the same `venue_rejected` hold with one alert whatever the rejection text.
`escalate_failed` is a retry marker only — a peer that becomes busy under it still takes the `peer_busy` hold and its alert.

---

## Secrets

Set in systemd overrides or exported environment variables before installation:

| Variable | Description |
| --- | --- |
| `DISCORD_BOT_TOKEN` | Discord bot token |
| `DISCORD_OWNER_ID` | Discord user ID for DM upgrades and migrations |
| `STATUS_AUTH_TOKEN` | Optional bearer token for `/status` |
| `ANTHROPIC_API_KEY` | Required only when a strategy opts into `llm_entry_analysis` |
| `GO_TRADER_GITHUB_TOKEN` | Token for `/go-trader-report-an-issue` (falls back to `GITHUB_TOKEN`) |
| `BINANCE_API_KEY`, `BINANCE_API_SECRET` | Binance live |
| `HYPERLIQUID_SECRET_KEY`, `HYPERLIQUID_ACCOUNT_ADDRESS` | Hyperliquid live |
| `TOPSTEP_API_KEY`, `TOPSTEP_API_SECRET`, `TOPSTEP_ACCOUNT_ID` | TopStep live |
| `ROBINHOOD_USERNAME`, `ROBINHOOD_PASSWORD`, `ROBINHOOD_TOTP_SECRET` | Robinhood live crypto orders; when set, paper Robinhood checks and the options check also log in for authenticated option data (expiry, strikes, premiums, Greeks); options run as paper only and place no order; the login session is kept in `<config folder>/robinhood-session` (see Robinhood session below) |
| `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_PASSPHRASE`, `OKX_SANDBOX` | OKX live/demo |
| `LUNO_API_KEY_ID`, `LUNO_API_KEY_SECRET` | Luno live |
| `GO_TRADER_ALLOW_MISSING_STATE` | `1` only for a genuine first-run live deployment |
| `GO_TRADER_MIGRATE_FIXTURE_HOLD_LATCH` | `1` only in the migrate layout fixture's latch scenario. A paper kill switch with no DM owner stays latched, and the daemon logs that this variable suppressed the auto-reset. Do not set it on a running service |
| `GO_TRADER_CASHFLOW_JOURNAL_ALARM` | `0`/`off`/`false`/`no` forces the legacy trade-ledger drift basis for HL shared wallets (default on) |
| `GO_TRADER_HL_BATCH` | `0`/`off`/`false`/`no` disables batched Hyperliquid signal checks |
| `GO_TRADER_EGRESS_PROXY` | Egress proxy URL (`http`, `https`, `socks5`, `socks5h`; explicit port; optional credentials); a non-empty value turns on egress proxy mode (§ Egress Proxy Mode) |
| `GO_TRADER_EGRESS_DEPLOYMENT` | Egress deployment name; set by the egress drop-in or `docker/compose.egress.yaml`, not by hand |
| `GO_TRADER_NOTIFY_GITHUB_REPOS` | Notification broker only: comma-separated repositories it may file issues in (default `richkuo/go-trader`) |

### Robinhood session

Every Robinhood login (signal check, execute, emergency close, position fetch, options check, chart and balance check) reuses one stored session in a folder that go-trader owns (#1818).
The folder is `<folder of the resolved --config file>/robinhood-session`: `/var/lib/go-trader/robinhood-session` for `go-trader.service`, `/var/lib/go-trader/<instance>/robinhood-session` for `go-trader@<instance>`, and `/data/robinhood-session` in the container image.
go-trader sets `GO_TRADER_ROBINHOOD_SESSION_DIR` to that folder at startup, directly after the config loads, and replaces any inherited value; the startup log shows `[robinhood] session folder: <path>` when a strategy uses Robinhood.
The Python scripts never use the home folder (`$HOME/.tokens`) and have no fallback: a missing, empty, relative, non-normalized, non-canonical, symlinked, foreign-owned or unwritable folder value refuses the login before any request, so live work fails with `Robinhood login failed:` and paper work continues logged out.
The folder is mode 0700 and every file in it is 0600, owned by the service user: `robinhood-<key>.pickle` (the session, which holds access and refresh tokens), `robinhood-<key>.device` (the device token), `robinhood-<key>.lock` and, after a device challenge, `robinhood-<key>.verification`; `<key>` is the first 16 hex characters of the SHA-256 of `go-trader/robinhood-username/v1:` plus the lowercased, trimmed username.
One lock per account serializes every login, and one watchdog bounds the lock wait and the login together (15 seconds, less for order scripts with a shorter budget, 600 seconds for the bootstrap); a waiter that runs out of time fails with `session lock busy` and sends no request.
robin_stocks writes into a private stage folder under the lock, and the stage replaces the stored session only after the login checks pass, so a failed login keeps the stored session and device token unchanged; the stored session is read with a reader that refuses any pickle global.
A scheduler login never answers a device verification prompt: it fails at once with an error that names the bootstrap, and it writes the verification latch, so later logins send no request until the bootstrap succeeds.
Deleting `robinhood-<key>.verification` allows one more automatic attempt.
Bootstrap on a host, as the service user, with the same sandbox as the unit: `sudo systemd-run --pty --collect --uid=go-trader --gid=go-trader -p WorkingDirectory=/opt/go-trader -p EnvironmentFile=/opt/go-trader/.env -p StateDirectory=go-trader -p ProtectSystem=strict -p PrivateTmp=true -p NoNewPrivileges=true -p "ReadWritePaths=/opt/go-trader/scheduler /opt/go-trader/logs" /opt/go-trader/go-trader robinhood-login --config /var/lib/go-trader/config.json`.
For `go-trader@<instance>`, use the instance service user, `/opt/go-trader-<instance>` for the working folder, `.env`, `scheduler` and `logs`, `StateDirectory=go-trader/<instance>` and `--config /var/lib/go-trader/<instance>/config.json`.
In the container image: `docker compose run --rm cli robinhood-login --config /data/config.json` (docs/DOCKER.md).
The bootstrap needs a terminal (`--pty`), runs the real robin_stocks verification flow (app approval, SMS or email code), and can run while the service runs: scheduler logins during it fail with `session lock busy` and retry next cycle.
Each instance keeps its own session, and a backup of the state folder or `/data` holds the session token, so protect it like the `.env` file.
After a service-layout migration the folder moves with the config, so the first login after it can need the bootstrap.

### Robinhood candles

Robinhood crypto candles come only from Yahoo through yfinance (`_get_yahoo_ohlcv` in `platforms/robinhood/adapter.py`), for the primary timeframe, the higher-timeframe (HTF) filter, the regime producer and the chart (#1822).
`ROBINHOOD_TIMEFRAMES` in the adapter is the only mapping from a go-trader timeframe to a Yahoo interval; Go `robinhoodTimeframes` (`scheduler/robinhood_timeframes.go`) mirrors its keys, Yahoo intervals and stock (session) intervals, Go `robinhoodYahooCryptoMap` mirrors `YAHOO_CRYPTO_MAP`, and `TestRobinhoodTimeframeTablesMatchThePythonAdapter` (`pyintegration`) fails on any drift.
The lookup is exact and case-sensitive with no trimming: `1M` is one month and `1m` is one minute, and any other name raises `unsupported Robinhood timeframe '<name>'` with the sorted list instead of returning an empty frame.

| go-trader timeframe | Yahoo request |
| --- | --- |
| `1m`, `2m`, `5m`, `15m`, `30m`, `90m`, `1d` | the same interval |
| `1h`, `60m` | `1h` |
| `1w` | `1wk` |
| `1M`, `1mo` | `1mo` |
| `2h`, `4h`, `6h`, `8h`, `12h` | `1h` rows resampled into UTC epoch buckets (crypto pairs only) |

- Resampling runs only for Yahoo crypto pairs: a symbol in `YAHOO_CRYPTO_MAP` (`BTC`, `ETH`, `SOL`, `DOGE`, `AVAX`, `LINK`, `ADA`, `DOT`, `MATIC`, `SHIB`, matched upper-cased) or one that already ends in `-USD`; any other symbol is sent to Yahoo upper-cased as a stock ticker. A closed bucket is emitted only with every source hour present and finite; a partial leading bucket is dropped, an incomplete interior bucket is dropped and counted in one `dropped N incomplete <tf> buckets` stderr line, and the bucket that holds the forming hour is the last row when its hours form a gap-free prefix. OHLCV is first open, max high, min low, last close and summed volume.
- A stock symbol (a Robinhood options underlying) gets Yahoo's own session-shaped `4h` interval for `4h` (the fixed options regime timeframe) and the unsupported error for `2h`, `6h`, `8h` and `12h`.
- Crypto rows of a fixed interval (`1m` to `1d`) must sit on their UTC interval grid: a final off-grid row inside the current interval is moved to its grid opening, and any other off-grid, duplicate or out-of-order row is an error.
- The range uses standard Yahoo periods only (`1d`, `5d`, `1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `10y`, `max`; no `start=`): the smallest period that holds `limit + 2` bars (plus one target bucket when resampling) with a 25% margin, capped by the source interval (`1m` at `5d`, `2m` to `90m` at `1mo`, `1h` and every resampled name at `1y`). Stock symbols use the cap for intraday sources. A Yahoo error or an empty frame still returns no rows, and the check's `< 30` guard decides.
- Config load (`robinhoodTimeframeConfigErrors` in `validateConfig`, so startup, SIGHUP and `go-trader probe`) refuses a Robinhood `check_robinhood.py` strategy whose `args` timeframe, or effective HTF interval with `htf_filter`, is not in the table, and a Robinhood spot strategy whose effective `regime.timeframe` is not in the table. It also refuses a raw `regime.timeframe` of `1M`, which go-trader lowercases to `1m` (one minute) for every platform; write `1mo` or `1m`. Each error names the strategy, the role and the value with the sorted list.
  For a symbol that is not a Yahoo crypto pair (for example `XRP` or `AAPL`), it also refuses an `args` timeframe or `regime.timeframe` of `2h`, `6h`, `8h` or `12h`, naming the symbol, its Yahoo ticker and the crypto list, so the adapter's `only for crypto pairs` error never reaches a cycle.

---

## Run And Install Service

```bash
./go-trader --config scheduler/config.json --once     # smoke test
mkdir -p logs
export DISCORD_BOT_TOKEN="{token}"
sudo bash scripts/install-service.sh
```

The installer copies the unit, runs `daemon-reload`, enables, starts, and pre-creates `logs/` so `ProtectSystem=strict` does not block first-run logging.

Templated multi-instance: `sudo bash scripts/install-service.sh systemd/go-trader@.service paper-testing`. A shared market feed service installs the same way (`... go-trader@.service feed-primary`) with a `role: "feed"` config (§ Shared market feed). Install without starting: `NO_START=1 sudo bash scripts/install-service.sh`.

```bash
sudo systemctl start|stop|restart|status go-trader
journalctl --namespace=+go-trader -u go-trader -n 100 --no-pager
```

**Journal namespace.** Both shipped units set `LogNamespace=go-trader`: every go-trader unit (plain and `go-trader@<instance>`) logs to one separate journal under `/var/log/journal/<machine-id>.go-trader/`, with its own size cap, and nothing is forwarded to syslog.
A plain `journalctl -u <unit>` shows only systemd's own start, stop and exit lines, which stay in the default journal.
`--namespace=go-trader` shows the go-trader output alone; `--namespace=+go-trader` merges it with the default journal, so the exit lines and any pre-move history appear too.
Before it installs the unit, the installer syncs `systemd/journald@go-trader.conf` to `/etc/systemd/journald@go-trader.conf` (`Storage=persistent`, `SystemMaxUse=2G`, `ForwardToSyslog=no`).
That file is managed: a differing copy is replaced, kept as `.prev`, and a warning names the drop-in directory.
A symlink there is left alone.
After a change, `systemctl try-restart systemd-journald@go-trader.service` applies it (journald keeps the service's log streams across the restart).
If that restart fails, the previous file is put back and the install stops before the unit.
Local settings (for example a different `SystemMaxUse`) go in `/etc/systemd/journald@go-trader.conf.d/*.conf`, followed by `sudo systemctl restart systemd-journald@go-trader.service`.
`LogNamespace=` needs systemd 245 or newer.
On an older systemd the installer warns, skips the config, and continues; systemd ignores the unknown unit line and the logs stay in the default journal.

**Config out of the deploy tree.** Both units set `StateDirectory=go-trader[/%i]` and point `ExecStart --config` at `/var/lib/go-trader[/<instance>]/config.json`. For an existing in-tree deploy, stop the service first, then run `scripts/migrate-config-out-of-tree.sh [--instance <name>]` — it refuses while the daemon is live. `scheduler/config.json` stays as a transition symlink; a config-version migration that rewrites the file replaces that symlink with a regular in-tree file, so prefer the out-of-tree path.

**Moving a hand-made unit to the template layout (optional).** `scripts/migrate-service-layout.py` moves one custom scheduler unit (for example `go-trader-live.service` run as root from a workspace) to `go-trader@<instance>.service`: code in `/opt/go-trader-<instance>`, config in `/var/lib/go-trader/<instance>/config.json`, user `go-trader`, the template's sandbox and journal namespace. The binary, the source version, the effective config and the status port do not change. Updates never run it. Run every subcommand as root, one unit at a time:

1. **`plan --unit <old> --instance <name>`** is read-only: it creates no journal or lock file and changes no unit, config, secret, tree, database, sidecar or lock content.
   It needs the old unit active and on this release (its `/health` carries `run_evidence`).
   It prints the source and target, each state file's canonical source and target path (`db_file`, `paper_db_file`, every `paper_sources[].db_file`; a file outside `scheduler/` of the tree moves to `/var/lib/go-trader/<name>/`, and the config value changes with it), every config and environment change (variable names only, never values), each access change (the target config and every database become 0600 `go-trader`, the config directory is created 0700, and the tree root is 0700 when other accounts cannot reach the source tree, else 0755), strategies the running daemon's kill switch held in its latest cycle, the journal command, service references and tunnel notes, the execution-proof bound, and a plan id.
   The running daemon must hold the ownership lock of every mapped file and no other state file, so a discovery error is refused and never omits a database.
   `replay_log_path` stays shared and unchanged, and only an absolute path under `/var/lib/go-trader/shared/` is accepted.
2. **`apply --unit <old> --instance <name> [--plan-id <id>] [--confirm-live <old>] [--exec-timeout <s>]`** re-inspects first.
   A unit with a live or `manual` strategy needs `--confirm-live <old>`, and a stale `--plan-id` is refused, both before any change.
   Before the old unit stops, it creates the `go-trader` account when needed, copies the tree without state files, locks, logs, `.env`, the tuning cache or other units' configs (a git-tracked file that only a generic pattern such as `*.db` matches, for example a test fixture, is still copied) and proves the copy (checksums, the binary hash, and a fingerprint of the tracked files, or of the code files in a tree deployed without `.git`), gives the tree to `go-trader` as the README template install does (root can still run `scripts/update.sh` and `scripts/shared-feed-convert.sh` there with no `safe.directory` setting;
   Auto-Update, "Root on a tree another account owns"), writes the config (transition symlink in the new tree;
   the original stays as evidence) and a 0600 `.env` built from `Environment=` and `EnvironmentFile=`, runs the binary's `probe` and write checks as `go-trader` under the template sandbox, checks that systemd reads the new `.env` to the same values, and installs the shared template and journald config only where they are absent.
   It then stops, disables and masks the old unit, takes the ownership and manual-action locks of every source and target file (primary, paper, then sources by id), snapshots each stopped file with its WAL, copies the tuning run history to the new config directory, writes a checkpointed copy to the target, and proves identical content table by table plus a clean `storage-inspect` by the target binary before any target start.
   It releases only the target locks, installs with `NO_START=1`, starts the new unit, and requires `/health` with the unit's pid, the old version and the old port, then every strategy evaluated, skipped at zero capital, or held by the portfolio kill switch, and a later successful save (a strategy held for any other reason fails the proof at once), read back from the primary file.
   The old unit stays stopped, disabled and masked, and its tree stays in place.
3. **`rollback --instance <name> [--confirm-live <old>]`** refuses with no change (exit 31) when a source file or config changed after the apply snapshot, or when the new unit's config names a state file (`db_file`, `paper_db_file`, a `paper_sources[].db_file`) that apply did not move and that the old unit would resolve to another path, or that lies inside a directory the rollback moves aside (a folded paper source that stays at its own absolute path passes).
   An automatic recovery holds both units stopped in that case (exit 30).
   Otherwise it stops and disables the new unit, takes both lock types on both sides, copies the new unit's latest files to the recovery evidence, writes them back to the original canonical paths (a config the new unit changed is mapped back), restores the old unit's file, enable links and state, starts it, and moves `/opt/go-trader-<name>` and `/var/lib/go-trader/<name>` aside as `.rolled-back-<txn>` (tuning runs made meanwhile stay there).
   A repeat is a no-op, and a later `apply` starts a new transaction.
4. **`status [--instance <name>]`** is read-only and prints the journal, both units, the lock holders and `/health`.

Any failure recovers on its own.
Before the old unit stops, the new artifacts are removed (exit 20).
Before the new unit's enable, the old unit restarts on its own files (exit 21).
From the enable on, the new unit's records are authoritative, including after an uncertain start, and the reverse transfer above runs (exit 22).
A process loss or reboot leaves a journal in `/var/lib/go-trader/service-layout/<name>/journal.jsonl`; at most one unit is enabled at any stage, `apply` refuses (exit 18) until `rollback` finishes the recovery, and SIGINT, SIGTERM and SIGHUP run the same recovery as a failure (SIGHUP output goes to `interrupted.log`).
The initial snapshot, the config copy and every recovery copy stay under `/var/lib/go-trader/service-layout/<name>/<txn>/evidence/`.
A recovery that cannot confirm the stop, prove the state, or finish the transfer never restarts from the initial snapshot: it stops and disables both units (the new one is also masked, and the old one stays masked until its restore), exits 30 and names the evidence; `rollback` then resumes it.

Refusals come before any change: 10 unit (unsupported directives such as `ExecStartPre`, extra `ExecStart` flags, aliases, units that other units require), 11 config or databases, 12 binary or health contract (restart pending, older release, `/health` not answering with the unit's pid), 13 environment (`PATH` or `UV_CACHE_DIR`, `PassEnvironment`, a running environment that differs from the unit's inputs, values that name the old tree), 14 target instance taken, 15 shared host files (an installed `go-trader@.service` or `journald@go-trader.conf` that differs from this tree, a foreign-owned file in `/var/lib/go-trader/shared`, a replay layout that cannot stay shared), 16 target runtime (a venv interpreter the service account cannot run, a venv tied to the old tree, `uv` or Go that root or the service account cannot run, low disk space;
`apply` also stops here with exit 20 when the sandboxed `probe` fails), 17 live confirmation missing, 18 another run holds the lock or a transaction is incomplete, 19 stale plan id.
`market_feed: shared` consumers and `role: feed` services are refused; use `scripts/shared-feed-convert.sh` for those.
After the move, `scripts/merge-paper-instance.sh` folds migrated paper deployments with its default paths.
`scripts/test_migrate_service_layout_fixture.sh` proves these rules on a Linux systemd host; the `shell-suites-migrate-<n>` CI jobs run its scenarios in shards (see Tests).

**File ownership.** Before any migration, startup write, probe or trading, the scheduler takes an exclusive lock on **every** configured state file, in role order (primary, then paper); a failure releases everything already held and exits with code 79 (`ExitSingletonLock`), naming the file and the holder pid. `--once` takes ownership too, so it refuses to run while the daemon is up — the daemon drains queued manual fills on its own next cycle. Startup then runs the read-only ownership check (`storage-inspect`); a rejected layout exits with code 80 (`ExitStorageOwnership`). Manual commands and the dashboard take only the owning file's manual-action lock, so they still work beside a running daemon; `backfill --apply` takes full ownership plus both manual-action locks.

**Startup probe.** Every unique check script runs with `--probe-only`. A non-zero result logs, DMs the owner, and exits with code 78 (`ExitProbeFailure`). Both unit files set `RestartPreventExitStatus=78 79 80`, so the service stays down instead of crash-looping. A probe failure right after an update almost always means `shared_scripts/` was not updated or the binary was not rebuilt — rerun `scripts/update.sh`.

**Graceful shutdown.** The daemon drains side-effecting subprocesses for up to 15 seconds, then SIGKILLs; state save, notifier flush, and DB close run afterwards. The unit sets `TimeoutStopSec=20`. Service-file edits need `daemon-reload`; `scripts/update.sh --restart` runs it for you when it installs a changed shipped unit (see Auto-Update § Unit sync), so a shipped unit change reaches a deployment on the next update instead of waiting for a hand-run `install-service.sh`.

---

## Docker Container

`docs/DOCKER.md` is the operator guide (install, setup, backup, restore, upgrade, rollback limits). systemd stays the canonical Linux production deployment, and the Linux process deployment is unchanged. The mechanism:

- **Image.** The root `Dockerfile` builds the binary (`-buildvcs=false`, `-X main.Version`, `-X main.SourceCommit`) and the venv (`uv sync --frozen --no-dev`) from one commit, and copies only the runtime allowlist (`shared_scripts/`, `shared_strategies/`, `shared_tools/`, `platforms/`, `backtest/`) into `/app`. `.dockerignore` starts from `*`, re-includes that allowlist and strips tests, state, configs, locks, logs and secrets. The image runs as uid/gid 10001 with `GO_TRADER_RUNTIME=container`, owns empty `/data` (0700) and `/app/logs` (0750), and sets `GO_TRADER_OHLCV_CACHE_DB=/data/ohlcv_cache.sqlite3`.
- **Provenance.** `go-trader version --json` prints `version` and the embedded `source_commit`; `--version` prints the release alone. The image labels `org.opencontainers.image.version` and `org.opencontainers.image.revision` carry the same values.
- **Runtime policy** (`runtime_policy.go`). `GO_TRADER_RUNTIME` is read once in `main` before any dispatch: unset means host, `container` means container, any other value exits 2. Container mode is never inferred from the filesystem.
- **Container config rules.** Every path-based config load (`LoadConfig`, `LoadConfigForProbe`, `LoadConfigReadOnly`; so startup, `probe`, SIGHUP reload and the dashboard and Discord config writes) refuses `auto_update` other than `off`, `role: "feed"`, `market_feed: "shared"`, a `status_port` other than 8099, and any `db_file`, `paper_db_file`, `paper_sources[].db_file` or `replay_log_path` that is in memory or resolves outside `/data`. The ledger-export config load is exempt because it binds snapshot paths.
- **Volumes.** The daemon and `supervise` refuse to start unless `/data` and `/app/logs` are mount points (read from `/proc/self/mountinfo`) and writable by the running uid; the message names the uid and docs/DOCKER.md § Volume permissions. A new empty named volume takes the image directory's owner and mode on first mount, so first setup needs no chown, and nothing changes the owner of existing content.
- **Setup.** In container mode `init` defaults `--output` to `/data/config.json`, never prompts for a path, refuses an output outside `/data`, skips the live-mode and manual-trading prompts, refuses a live or `manual` strategy and an `autoUpdate` other than `off` (interactive and `--json`), and writes `db_file` next to the config (`/data/state.db`). On a host, interactive `init` offers the `--output` value as the path default (still `scheduler/config.json`).
- **Status bind.** `validateStatusBind` runs right after the config load, before ownership and before any listener: loopback always passes, a host refuses everything else, and a container accepts only `0.0.0.0` or `::` and only with `STATUS_AUTH_TOKEN`. Every bearer check (`/status`, `/history`, `/health` run evidence, `requireAPIAuth`) compares in constant time.
- **Updates, restarts and logs.** In container mode `checkForUpdates` returns before Git, `applyUpgrade` DMs the image-pull steps, `restartSelf` signals the daemon's own stop path (drain, state save, exit 0) instead of `systemctl` or `syscall.Exec`, `/logs` returns the `docker compose logs` command, and the dashboard restart message names `docker compose restart go-trader`.
- **Supervisor** (`go-trader supervise`, container only). Compose runs it behind `init: true`. It starts one daemon in its own process group, forwards SIGTERM, SIGINT and SIGHUP, and exits with the daemon's status (128 plus the signal number for a signal death). On 78, 79 or 80 without a pending termination it prints one `[supervisor] CRITICAL` line and holds with no daemon until SIGTERM or SIGINT, then exits with that code; the health check fails while it holds. It refuses a subcommand argument, so utilities never run under it.
- **Health.** `go-trader healthcheck [--url] [--timeout]` exits 0 only on an HTTP 2xx from `http://127.0.0.1:8099/health` (§ Status gives the `/health` rule). The daemon refuses `--status-port` other than 8099 and binds 8099 with no fallback port, so the bound port is always the one Compose publishes and the health check reads. Discord platform setup names `docker/go-trader.env` and `docker compose up -d --force-recreate go-trader`, because a restart keeps the old environment.
- **Compose** (`docker/compose.yaml`). Service `go-trader` runs `supervise --config /data/config.json` with `GO_TRADER_STATUS_BIND=0.0.0.0`, publishes `127.0.0.1:${GO_TRADER_HOST_PORT:-8099}`, and has its own network, `restart: unless-stopped`, `stop_grace_period: 20s`, a read-only root, a `/tmp` tmpfs, `no-new-privileges` and every capability dropped.
  Service `cli` (profile `cli`) has the same image, volumes and hardening with `restart: "no"`, no health check and no published port; it runs `init`, `probe`, `inspect`, `storage-inspect`, manual commands and `--once` (which needs the service stopped).
  `GO_TRADER_TAG` in `docker/.env` is required and pins the image tag; secrets come from `docker/go-trader.env`. `docker/compose.build.yaml` builds `go-trader:local` from the checkout.
- **Docker restart behavior** (verified by `scripts/test_container_image.sh`): a daemon crash after a healthy start (exit 137) and an intentional restart (exit 0) both restart the container; an early exit 1 (for example a refused `auto_update`) restarts with Docker's doubling delay; a held 78, 79 or 80 stays running and unhealthy with no restart.
- **Reload.** Send SIGHUP through the container's init: `docker compose exec go-trader sh -c 'kill -HUP 1'`. `docker compose kill -s HUP` reaches the daemon too, but Docker Engine (checked on 29.8.2) then marks the container as stopped by hand, so the next exit is not restarted until `docker compose restart go-trader`; `docs/DOCKER.md` states this.
- **CI** (`.github/workflows/container.yml`). Every pull request and `main` push builds the image natively on `linux/amd64` and `linux/arm64` with canary files planted in the build context, then runs `scripts/test_container_image.sh` under `scripts/run_ci_shell_suite.sh`. The `image` jobs get a read-only token, and their checkout keeps no credential (a step fails if one is left in `.git/config`). On a published release each `image` job saves its tested image as an artifact; the `publish` job, the only job with `packages: write`, checks out no code, loads both images, refuses one whose image ID or commit differs from the tested one, pushes each by digest and joins them under the release tag, the source commit and `latest` (not for a prerelease).

---

## Egress Proxy Mode

Egress proxy mode sends every external connection of the scheduler, the feed service, manual venue commands and every child process through one configured proxy, and a kernel boundary blocks every other route (issue 1841).
With the mode off (the default) nothing changes: no new environment variable is read into a policy, no child argument changes, and the clients keep their existing transports.

**Turning it on.**
- `GO_TRADER_EGRESS_PROXY` in the unit's environment file (or `docker/go-trader.env`) holds the proxy URL: `http`, `https` (TLS to the proxy), `socks5` or `socks5h`, a host, an explicit port, and optional `user:password`.
  A path, query or fragment is refused.
  Both SOCKS forms send venue host names to the proxy; no venue name is resolved locally.
- A non-empty value turns the mode on, and an invalid value refuses every entry path, even with no `egress` block.
  Error text names only `scheme://host:port` (or a scheme-free reason); credentials never reach logs, status, alerts, child output or the manifest.
- The optional top-level `egress` block takes only `proxy_required` and `notifications_direct` (booleans, default false; any other key or type is refused by the scheduler, feed, probe and read-only loaders).
  `proxy_required: true` refuses entry when the variable is unset or empty.
  `notifications_direct: true` without the mode is refused.
- The policy is frozen per process.
  A SIGHUP that changes the `egress` block is refused in the scheduler and the feed (restart required), and the environment is read once.
- A research pair's public quote read (#1871, `pairPublicEgressReady`) establishes the policy with attestation and refuses to start its reader unless the runtime is active, attested and has not lost its attestation whenever a proxy is configured or required; an earlier unattested resolution does not count, and there is no direct fallback.

**Enforcement boundary** (`scheduler/cmd/egress-controller/`, `scheduler/internal/egresspolicy/`).
- A root-owned controller (`go-trader-egress@<deployment>.service`, binary `/usr/local/libexec/go-trader-egress/<deployment>/go-trader-egress-controller`, manifest `/etc/go-trader/egress/<deployment>.json`) owns a network namespace, `go-trader-egress-<deployment>`, whose only usable link is loopback.
- Inside it, table `inet go_trader_egress` drops all output, input and forward traffic except loopback; the output rule comment carries the installed generation.
  Rules are installed in one `nft -f` transaction before the controller serves, re-verified every 15 seconds and on every attestation (exact chains, policies, rules, and no non-loopback link up), and reinstalled with a new generation when they drift.
- The controller listens inside the namespace on the proxy address the workers dial: `127.77.0.1:<port>` for a proxy host name (the namespace hosts file maps the name there), the proxy's own literal address (added to `lo`), or the loopback address for a host-loopback proxy.
  It relays each TCP connection to the proxy, so workers never route past loopback and the controller is the only process that resolves the proxy name.
- The controller re-resolves the proxy name every `resolve_seconds` (default 60, minimum 10).
  It accepts several addresses, refuses loopback, link-local, unspecified and multicast answers for a name, swaps the set atomically, and closes relayed connections to removed addresses.
  A failed resolution keeps the last good set and shows the error in `status`; with no good set the controller is not ready and workers refuse to start.
- Name service inside the namespace is the hosts file only (`/run/go-trader-egress/<deployment>/resolver/{hosts,resolv.conf,nsswitch.conf}` bind-mounted over `/etc`), and the drop-in makes the host resolver, D-Bus, nscd, Avahi, NetworkManager and container-runtime sockets inaccessible, so no venue name leaves through a host daemon.
  Filesystem Unix sockets (the shared feed socket) keep working.
- Workers run with no capabilities, `NoNewPrivileges`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`, `ProtectProc=invisible` and `SystemCallFilter=~@debug` (no ptrace or cross-process memory calls).
  On a host the controller refuses attestation while the worker account runs any process outside every enforced worker namespace (`go-trader-egress-*` with verified rules), so a worker cannot reach an unconfined process of its own account; instances of one account (`go-trader@.service`, a shared feed and its consumers) may share it when each runs in its own enforced namespace.
  A raw socket, a direct TCP or UDP connection over IPv4 or IPv6, ICMP, a direct DNS query, curl_cffi, and a child that never loads the Python bootstrap all fail with "network unreachable".
- The controller stopping or dying leaves the namespace and its rules in place; only the relays close, so proxy traffic fails closed until it restarts.
- **Attestation.** Every network entry path asks the controller over `/run/go-trader-egress/<deployment>/control.sock` (protocol 1; the peer must be root).
  The controller reads the caller's `SO_PEERCRED`, then checks the uid, that effective, permitted and ambient capabilities are empty, that `no_new_privs` is set, that the process did not change during the check, and that its network namespace is the managed one; it re-verifies the rules and requires a ready relay.
  The worker then checks that its own namespace and the configured proxy identity match the answer.
  An environment variable or a file cannot stand in for this answer.
  The daemon and the feed re-attest every 60 seconds (status `last_attested_at`, `attest_error`, `enforcement: unverified` while it fails); when the answer says the process is no longer in the managed namespace (for example after the Docker anchor restarted), the daemon DMs the owner with **EGRESS NAMESPACE LOST** and restarts, and the feed exits 1 so its unit restarts it, so both rejoin the enforced namespace.
- Supported deployments: Linux system services under systemd 247 or newer (`NetworkNamespacePath`, `ProtectProc`), rootful Docker Engine on a Linux host with the controller on the host, and manual commands through the controller launcher.
  Anything else, including a desktop shell or macOS, refuses the mode.

**Entry paths** (`egress.go`, `egress_gate.go`).
- The daemon (including `--once`, `--summary`, `--leaderboard`) and the feed service establish the policy right after the config load and before the notifier, ownership, capital sync or any client.
  Manual venue commands (`manual-open`, `manual-add`, `manual-close`, `force-close`, `manual-cancel`, `manual-update-sl`, `manual-cancel-sl`), `backfill`, `record-observations` (it opens the Hyperliquid websocket) and `robinhood-login` (its Python child runs under the bootstrap) do the same before dispatch.
- `probe` validates the policy and requires a ready controller with the same proxy identity (exit 78 otherwise); its Python children run under the bootstrap.
  It does not need the namespace, because it makes no external connection.
- Local commands (`init`, `export`, `inspect`, `storage-inspect`, `agent-info`, `diagnostics`, `feed-fetch`, `basket-replay`, `manual-clear-limit-row`) make no network call; when the variable is set they still pin their transports to the proxy, and they ignore `proxy_required`.
- `quote-replay` is offline (`egressOfflineSubcommands`): with no proxy variable it sets nothing up, and with the variable it records proxy mode with no configuration and no attestation, so it never reads `scheduler/config.json` or any deployed file.
- After attestation the daemon checks each Go client host it uses (Hyperliquid, OKX, Deribit, and Discord and Telegram when they go through the proxy) with a tunnel and a verified TLS handshake (no HTTP request, so no venue rate budget is spent), then runs `shared_tools/egress_bootstrap.py --check-policy <families>` for the Python client families of its strategies.
  The feed checks Hyperliquid in Go only.
  A failure is an ordinary exit 1, so the service retries; exits 78, 79 and 80 keep their meanings.

**Go clients.** Every venue client gets an explicit policy transport (`egressHTTPClient`), the feed websocket and Discord use the policy dialer (`egressWebsocketDialer`, `pinDiscordSession`), and `http.DefaultTransport`, `http.DefaultClient` and `websocket.DefaultDialer` are replaced in the mode.
One dialer (`egresspolicy.Dialer`) does HTTP CONNECT, CONNECT over TLS to an `https` proxy (the proxy certificate is verified against its host name), and SOCKS5 with user/password and domain targets, with context cancellation and deadlines and no direct fallback.
Host `HTTP(S)_PROXY`, `ALL_PROXY` and `NO_PROXY` never change routing in the mode.
Loopback and Unix-socket connections stay direct.

**Python children.** In the mode every launch (regular, side-effect, advisory, tuning, backtest and the probe launcher) runs `.venv/bin/python3 shared_tools/egress_bootstrap.py <script> <args>`.
Go applies per-call overrides first and then restores `GO_TRADER_EGRESS_MODE=proxy`, `GO_TRADER_EGRESS_PROXY`, upper- and lowercase `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (socks5 becomes socks5h) and `NO_PROXY=localhost,127.0.0.1,::1`.
The bootstrap refuses a missing or invalid policy with `{"error": "egress policy: ..."}` and exit 1 before the target is imported, then runs the target exactly as plain Python would.
`shared_tools/egress_policy.py` pins each client family: ccxt (`httpsProxy`, or `socksProxy` with socks5h, plus a safety-net wrapper on `Exchange.__init__`), the Hyperliquid SDK at its base `API.__init__` (every `Info`/`Exchange` session before its first request), the robin_stocks module session before login, TopStep and Deribit requests, yfinance (`YfConfig.network.proxy` for curl_cffi), and the advisory script (`llm_review.py` now uses a pinned requests session).
At exit a child that made proxied requests writes one `go-trader-egress-outcome {"requests":N,"proxy_failures":M,"last_error":"..."}` line to stderr; Go adds it to the status and removes it before any stderr consumer sees it.
A pinned client verifies the proxy certificate against public roots only, so an `https` proxy with a private CA works only for clients that read `REQUESTS_CA_BUNDLE`.

**Notifications.** By default Discord REST and gateway, Telegram and GitHub issue reports use the proxy.
With `notifications_direct: true` they go through the notification broker (`internal/notifybroker`, run as `notify-broker --deployment <name>` under its own account and its own loopback-only namespace `go-trader-notify-<deployment>`).
The broker holds only the notification tokens (`/etc/go-trader/notify-<deployment>.env`, written by the installer with only `DISCORD_BOT_TOKEN`, `TELEGRAM_BOT_TOKEN`, `GO_TRADER_GITHUB_TOKEN`, `GITHUB_TOKEN`, `GO_TRADER_NOTIFY_GITHUB_REPOS`), cannot read the state, config or venue credentials, and reaches the network only through a controller-owned CONNECT proxy at `127.77.0.2:3128` that allows `discord.com`, `*.discord.gg`, `api.telegram.org` and `api.github.com` on port 443 to public addresses.
The scheduler talks to it over `/run/go-trader-egress/<deployment>/notify/broker.sock` with typed operations (send, DM channel, interaction response, follow-up with attachments, command registration, an allowlisted Telegram call, an issue in an allowlisted repository); both sides check the peer uid, and there is no URL, request or dial operation.
Inbound gateway interactions and DMs come back as events; owner checks, command dispatch, config changes and trading stay in the scheduler.
The worker's network policy is the same in both notification modes.
In direct mode the scheduler and feed refuse to start while they hold a Discord, Telegram or GitHub token (environment or config), so only the broker holds them.
The app unit starts after the broker unit; if the broker is not answering yet the scheduler waits up to 30 seconds and then keeps reconnecting, and the broker keeps serving Telegram and issue reports while it retries a failed Discord gateway open.
The scheduler installs its interaction handler before it waits for the broker's Discord identity; if the identity is not there within 30 seconds it logs one warning and registers the slash commands in the background once the gateway is up.
A startup refusal in direct mode sends one owner DM per reason per alert throttle interval across restarts (`.egress-startup-refusal.json` beside the config, removed on a clean start).

**Status, alerts and outages.** `/status` (scheduler) and the feed `/status` carry an `egress` block: `mode`, the redacted proxy, deployment, `enforcement` and generation, last success and error times, the redacted last error, consecutive and total tunnel failures, and the Python child totals.
Three consecutive tunnel failures send one **EGRESS PROXY FAILING** owner DM, repeated at the alert throttle interval while they last, and one **EGRESS PROXY RECOVERED** DM on recovery (in proxy notification mode those DMs only arrive once the proxy is back).
During an outage venue calls fail or hold through the existing paths: an order error after submission keeps the unknown-outcome hold and recovery path, nothing is retried blindly, and nothing is booked without a confirmed fill.
The application cannot close positions through an unreachable proxy; resting venue protection that is already armed (for example an on-chain stop) keeps working, and a position without it has none.

**Install and operate (host).**
1.
   Set `GO_TRADER_EGRESS_PROXY` (and, if wanted, `"egress": {"proxy_required": true}`) for the unit, then from the deployment checkout run `sudo bash scripts/egress-install.sh host --deployment <name> --unit <unit> [--notify-user go-trader-notify]`.
2.
   The installer refuses a checkout that `update.sh` would refuse (a foreign-owned tree whose units can change it outside `scheduler/` and `logs/`), builds the controller from the checkout's committed sources (git archive, never the service-writable tree), installs it root-owned, writes the manifest (scheme, host, port only), runs `preflight` (tools, a scratch namespace with rules, proxy resolution, systemd version), starts the controller, writes the app drop-in (`/etc/systemd/system/<unit>.d/50-egress.conf`, from `render-units`) and the broker unit, restarts the app inside the namespace, and checks `/health` through the status relay (host `127.0.0.1:<status_port>` to the namespace loopback, a fixed destination).
   The controller does not need the status port free: while the pre-egress service or a published container port still holds it, the relay retries every 2 seconds and `status` shows `status_relay_errors`; the proxy relay and attestation do not depend on it.
3.
   Manual venue commands run through the restricted launcher: `sudo /usr/local/libexec/go-trader-egress/<name>/go-trader-egress-controller launch --manifest /etc/go-trader/egress/<name>.json -- manual-open ...`.
   It accepts only a manual or backfill subcommand, `record-observations`, `robinhood-login`, `probe`, or the daemon with `--once`, `--summary` or `--leaderboard`, and runs the manifest's binary as the worker in a transient unit with the same sandbox and every environment file of the unit (`host.environment_files`).
   Running `go-trader` from a shell with the variable set refuses.
4.
   `status --manifest ...` prints the controller state; `sudo bash scripts/egress-install.sh uninstall --deployment <name> --unit <unit>` removes the drop-in, broker, controller and namespaces (remove the variable before starting the unit again, or it refuses).
5.
   Updates stay `sudo bash scripts/update.sh --restart`.
   For a unit with an egress drop-in it must run as root: it builds the controller from the same exported sources, runs the probe as the unit's user with the unit's environment files, switches the controller (previous binary kept as `.prev`) before the app restart, and restores both on rollback.
   Controller restarts use `systemctl restart --job-mode=ignore-dependencies`, because the drop-in's `Requires=` would otherwise restart the app with the controller; the app restarts once, after the controller is ready.
   The namespace rules stay installed through the switch.
   In the mode the in-process updater and `auto_update` checks refuse (the service has no route to Git) and `/restart` asks the controller to restart the registered unit.

**Container deployment.** `docker/compose.egress.yaml` adds `egress-net` (the release image running `egress-anchor`, `network_mode: none`, labeled with the deployment) and joins `go-trader` and `cli` to it with `network_mode: service:egress-net` (no published port; the host status relay serves `127.0.0.1:<port>`).
The host controller (`scripts/egress-install.sh docker ...`, mode `docker`; the installer waits only until the controller answers, since the anchor starts in the next step) attaches to the anchor's namespace, writes its hosts and resolv.conf, installs the rules and relays, and re-attaches when the anchor restarts; the anchor health check passes only when the controller reports this namespace enforced, and the application still attests on its own.
The optional `notify-broker` service (profile `notify`) runs the broker under its own uid with `network_mode: none`.
`docker compose run --rm cli ...` commands join the same namespace. docs/DOCKER.md § Egress proxy mode has the steps.

**Remote proxies and Gluetun.** Any reachable proxy works, including a Gluetun sidecar or a remote host; point the URL at the address the host can reach (a published Gluetun port such as `http://127.0.0.1:8888` works through the relay).
Verifying the exit address is an operator task outside the application: compare a non-venue echo service through and outside the proxy by hand; the application never makes a direct comparison.

**Adapter integration requirement.** A new platform adapter or check script must build its clients through the `shared_tools/egress_policy.py` helpers (`pin_ccxt`, `pin_requests_session`, `requests_proxies`, `pin_yfinance`, `pin_hyperliquid_sdk`), and a new Go client must use `egressHTTPClient` or `egressWebsocketDialer`; the kernel boundary still blocks a client that forgets, but it then fails instead of using the proxy.

**Tests.** `scheduler/egress_integration_test.go` (every scheme, failures, cancellation, loopback, host proxy variables, config strictness, entry refusals), `scheduler/egress_broker_integration_test.go` (Linux: direct notifications through a real broker with a fake Discord gateway, Telegram and GitHub, during a venue proxy outage), `scheduler/egress_pyintegration_test.go` (`pyintegration`), `shared_tools/test_egress_integration.py`, `scripts/test_egress_enforcement.sh` (root: namespace rules, attestation, every direct path, relay-only egress, the daemon in the namespace, controller loss, address refresh, systemd sandbox and launcher) and `scripts/test_egress_docker.sh` (root, rootful Docker: anchor, application attestation, status relay, one-shot commands, anchor restart).

---

## Auto-Update

`auto_update`: `off` | `daily` | `heartbeat`. When an update is found the bot notifies active Discord channels. With `DISCORD_OWNER_ID` set it DMs the owner; replying yes within 30 minutes runs `scripts/update.sh`, saves state, and restarts.
A container deployment (§ Docker Container) refuses any value other than `off`, never runs an update check, Git, `scripts/update.sh` or `systemctl`, and answers an upgrade request with the image-pull steps.

```bash
# Systemd deploy (default)
cd /path/to/go-trader && bash scripts/update.sh --restart

# Linux bare-process deploy (no systemd)
cd /path/to/go-trader && bash scripts/update.sh --restart --restart-mode signal

# Sync from a source clone without clobbering secrets/state/venv/binary
bash scripts/update.sh --rsync-from /path/to/source-clone --restart

# Batch-update every discovered deployment (requires --restart)
bash scripts/update.sh --all --restart [--update-all-root <parent-dir>]
```

`scripts/update.sh` is the single source of truth for host updates: `git pull --ff-only` + `uv sync --no-dev` + `go build`, all gated under `set -euo pipefail`. External host deploy automation (for example Ansible) must call this script rather than reproduce the steps inline.
The Docker image is the one exception: the root `Dockerfile` builds Go and Python from one committed revision, and a container updates only by pulling a new image (`docs/DOCKER.md` § Upgrade). Never run `scripts/update.sh` inside or against a container.

**Unit sync.** With `--restart` in systemd mode, the `unit` phase (between the binary swap and the restart) compares the loaded unit file against the one this repo ships for the resolved unit name — `go-trader.service` for a plain unit, `systemd/go-trader@.service` for `go-trader@<instance>.service`.
On a difference it keeps the loaded file as `<unit>.prev`, installs the shipped one with mode 0644, and runs `daemon-reload`; on a match that systemd has not reloaded it runs `daemon-reload` only; otherwise it does nothing.
A rollback restores `<unit>.prev` and reloads before restarting the previous binary.
Before the binary swap, a `journal` phase runs the same journald namespace sync as the installer (Run And Install Service § Journal namespace); a failed sync removes `go-trader.new` and stops the update, so the binary, the unit and the running service stay unchanged.
Drop-ins under `<unit>.d/` are never written — `scripts/merge-paper-instance.sh` and `scripts/egress-install.sh` own those.
For a unit with an egress drop-in (`NetworkNamespacePath` plus `GO_TRADER_EGRESS_DEPLOYMENT`), the update must run as root: it builds the egress controller from the same exported sources into a root-owned staging directory, runs the probe as the unit's user with the unit's environment files, switches `go-trader-egress@<deployment>` to the new controller (the previous one kept as `.prev`) before the app restart, and restores both on rollback (§ Egress Proxy Mode).
A unit name this repo does not ship is logged and skipped, never guessed; a fragment that is a symlink whose target differs is left to the operator; a unit loaded from outside `/etc/systemd/system` (vendor or generator) stops the update before the swap.
`scripts/install-service.sh` is still the first-install path: it also creates `logs/`, enables the unit, and starts it.

**Root on a tree another account owns.** A tree that `scripts/migrate-service-layout.py` or `scripts/shared-feed-convert.sh` gave to a service account can be updated by root (`sudo bash scripts/update.sh --restart`, `--all --restart`, `--rsync-from`) with no git setting.
Every git call in `update.sh` and `shared-feed-convert.sh` goes through `update_git` in `scripts/update_helpers.sh`.
When root runs git on a checkout whose top directory or `.git` another account owns, that one command trusts only that exact checkout (`safe.directory` for its path, with `core.fsmonitor` and hooks off), passed in the command's environment.
Nothing is written to any git config, so do not add a root-wide `safe.directory`.
Every account other than root that owns the top directory or `.git` must pass this check, and a top and a `.git` that two different accounts own are refused.
Root builds and runs code from such a tree only when every systemd unit that runs as its owner (loaded or only installed as a unit file; a template is read with a placeholder instance) has `ProtectSystem=strict` and no write path (`ReadWritePaths`, `BindPaths`) over the tree root or inside the tree outside `scheduler/` and `logs/`, which is what the `go-trader@.service` template gives.
Otherwise the update stops before any change and lists each unit and write path, because the owner could then change git settings, the venv or the tracked scripts that root runs.
This also applies under `sudo` when the invoking account owns the tree, because a service running as that account cannot use its `sudo`; run the update as that account without `sudo` instead.
Without systemd, root does not update such a tree.
The probe of the new binary runs as the tree owner under the template sandbox (`systemd-run`, `ProtectSystem=strict`, writes only in `scheduler/` and `logs/`) on a private copy of `scheduler/config.json`, because the owner can change that config and the scripts it names; `shared-feed-convert.sh` probes a feed tree that the feed account owns the same way.
The Go build never compiles untracked files: `update.sh` exports the committed `scheduler/` sources of the checked-out commit (`git archive`) into a root-owned temporary directory and builds there with `GOWORK=off` and `-mod=readonly`, so a `.go` file or `go.work` that the service writes into `scheduler/` is never built.
With `--rsync-from`, the export comes from the source's `HEAD` when the source has no tracked changes.
A source that another account owns and that has tracked changes, or that is not a git checkout, is refused; a root-owned one builds from its own working tree, in the source's `scheduler/` with `GOWORK=off` and `-mod=readonly`, never in the deployment's copy, which the deployment's service can write.
When the files already match a release, `git reset <release>` moves `HEAD` without touching them.
As root, `uv sync --no-dev` runs with `UV_LINK_MODE=copy`, so the venv never shares files with root's uv cache.
After `uv sync --no-dev` as root, the owner must still run `.venv/bin/python3`, or the update stops before the swap.
Before any write, the update lists the paths in the tree that root already owns.
After `uv sync --no-dev`, after the build (so the owner can run the probe and the venv under any root umask), before the swap, and again when the script exits (also after a rollback), every other root-owned path, which this update wrote, is given back to the owner of the tree's top directory, so the owner can update the tree later.
When root owns the top directory and another account owns `.git`, the give-back covers `.git` for that account.
The give-back opens each directory without following symlinks and changes each owner through a handle to that entry, so a directory swapped for a symlink during the walk is never followed.
A file that shares its data with another path (a hard link) keeps its owner and is named, so a give-back never changes a file outside the tree.
The same rule applies when `shared-feed-convert.sh` gives a feed tree to the consumer user.
A path that root owned before the update stays as it is, also after a rename, because the list records each file's identity (device, inode and modification time) with its path; the update names examples and the `find <tree> -xdev -user root` command, so the operator can give only the paths the owner needs.
An rsync as root copies no owners from the source.
The build version comes from the tree, or from the `--rsync-from` source when that source is a git checkout; if git cannot read it the update stops and never stamps `dev`.
A git failure names the tree, its owner, the account that ran git, and the git error.
`scripts/test_migrate_service_layout_fixture.sh` (scenario `update`; Linux systemd host, run by a `shell-suites-migrate-<n>` CI job) proves the update, the rollback, the feed build and `--all` on such trees.

**`--rsync-from <src>`** replaces `git pull --ff-only` with an rsync from a source clone.
It keeps the deployment `.git/` frozen, and one shared exclusion builder (`update_managed_rsync_excludes`) protects `.git/`, `.env`, `scheduler/config.json` (and any in-tree symlink target), `tuning_runs/` and `ohlcv_cache.sqlite3*` next to the resolved config, configured state DBs (`db_file`, `paper_db_file`, `paper_sources[].db_file`, `replay_log_path`) and their WAL/lock sidecars, `.venv/`, `logs/` (and any custom `log_dir`), `robinhood-session/`, binaries, and the signal log across forward rsync, snapshot, and restore.
Use it when the deployment directory has local changes or was not cloned from origin.
Before the restart it warns on stderr about any required `EnvironmentFile=` the unit declares but the disk does not have; optional entries prefixed with `-` are skipped silently.

**Pre-update snapshot recovery.** Both git mode and `--rsync-from` mode recover from a protected pre-update snapshot taken before any write (`update_take_snapshot`), never from `git reset --hard` or from the deployment's `.git/` commit.
- The recovery root is `/var/lib/go-trader-update/<key>/` (0700 snapshot directory inside 0755 `/var/lib/go-trader-update`, root-owned) when run as root, or `${XDG_STATE_HOME:-$HOME/.local/state}/go-trader-update/<key>/` (0700) when run as non-root (`GO_TRADER_UPDATE_RECOVERY_DIR` overrides it for tests), where `<key>` is derived from the canonical deployment path.
- Before any write, `update.sh` checks that `rsync` is installed and compatible, verifies that free space in the recovery root exceeds the snapshot size (managed set, `.venv`, binaries, git index) plus 10 percent, and checks all candidate recovery roots for that deployment (system root, caller root, and tree-owner root) for an incomplete-recovery marker or a live `<key>.lock` (taken after the `--all` dispatch so a batch parent never blocks its children; a live holder refuses a concurrent update across accounts, and a dead holder's lock is reclaimed).
- The snapshot copies the managed code set (tracked edits and untracked files, without hard-linking to live files), the complete `.venv/` (or records its absence), `go-trader` and `go-trader.prev` (independent of `go-trader.prev` during the swap), git `HEAD` (symbolic or detached) and `.git/index` in git mode, and the installed systemd unit, journald namespace config, and egress controller files before any mutation.
- One exit coordinator (`update_exit_coordinator`) owns every failure after mutation starts (explicit `fail`, non-zero exit under `set -euo pipefail`, and catchable `SIGTERM`, `SIGINT`, `SIGHUP`, including `--all` forwarding `SIGINT` as `SIGTERM` to its child and Discord auto-update timeouts sending `SIGTERM` without killing recovery).
- On failure, the coordinator stops any running transfer or dependency child, stops the service first only if a restart was already attempted (leaving a healthy running trader untouched on pre-swap failures and on post-swap failures before the restart step), restores managed files with `rsync -a -c --delete` using the frozen exclusion list, restores git `HEAD` and `.git/index` in git mode while keeping staged and unstaged edits, atomically swaps the saved `.venv/` back in place via a sibling directory on the same filesystem, restores the binaries and any modified unit, journald, or egress controller files, restores file ownership, runs the restored binary's `probe` as the service owner under the unit sandbox, restarts the trader if it was stopped, and verifies `/health` for the previous version.
- The snapshot is removed only after file, `.venv`, ownership, probe, and service checks all pass; a recovered failed update still exits non-zero.
- If any recovery stage fails (including a failed post-recovery probe, which blocks starting the trader), the snapshot and `manifest.env` are kept, an incomplete-recovery marker `<recovery-root>/<key>.incomplete` recording the deployment directory's device, inode, and owner UID is written, and later updates of that directory by any account are refused until an operator inspects the deployment and runs `bash scripts/update.sh --clear-recovery` (`sudo bash scripts/update.sh --clear-recovery` for a root marker; a marker whose recorded directory no longer exists at that path or inode does not block and prints its location).
- Automatic recovery covers failures and catchable signals (`SIGTERM`, `SIGINT`, `SIGHUP`); `SIGKILL` and power loss get no automatic recovery, and the next `update.sh` run detects the unfinished snapshot (`COMPLETE` with recorded mutation intents), writes `<key>.incomplete`, preserves the snapshot, and refuses until cleared with `--clear-recovery`.

**Signal mode** (`--restart-mode signal` or `RESTART_MODE=signal`) SIGTERMs the PID in `GO_TRADER_PIDFILE` (default `./go-trader.pid`), respawns through `GO_TRADER_RUN_SH` (default `./run.sh`), then polls `/health` and PID freshness with the same verify-and-rollback flow as systemd mode. Generate a starter `run.sh` with `bash scripts/create-run-sh.sh`. Other signal-mode variables: `GO_TRADER_SIGNAL_LOG`. When systemd mode meets a missing unit (systemctl exit 5), update.sh retries in signal mode automatically if `go-trader.pid` and an executable `run.sh` are present.

**Batch mode** (`--all`) discovers deployments from the systemd `WorkingDirectory` of every loaded `go-trader`, `go-trader-*`, and `go-trader@*` unit, so siblings need not share a parent directory, and runs the full flow in each one sequentially. `--update-all-root <dir>` or `GO_TRADER_UPDATE_ALL_ROOT` pins the legacy `go-trader-*/` glob and skips discovery; that is also the automatic fallback when `systemctl` is absent or no units load. Skipped directories are logged on stderr, and a batch that updates nothing fails loudly. Each child resolves `GO_TRADER_SERVICE` from the active unit that owns its `WorkingDirectory`; a directory no active unit owns falls back to the parent's service and logs a warning.

Verify: `journalctl --namespace=+go-trader -u go-trader -f | grep -i "\[update\]"` (systemd) or `tail -f ./go-trader-signal.log` (signal mode).

**Preflight audits before a fleet update:**

```bash
bash scripts/check-config-versions.sh              # every deployment at or above the supported config floor
bash scripts/check-live-paper-config-drift.sh      # live/paper twin cadence + sizing drift
bash scripts/check-hl-stop-bankruptcy-bound.sh     # no HL stop sits past the isolated-margin bankruptcy distance
```

Each one auto-discovers active systemd deployments and accepts explicit deployment directories instead.
Each exits non-zero on a finding.
The drift audit pairs a paper strategy with its live twin only through an explicit `replay_source_id`, an equal id across deployments, or a `-paper` suffix backed by `storage_strategy_id` naming the live id; a suffix alone prints `AMBIGUOUS` and gates.
A `-paper` alias whose base names no live strategy in the audited deployments prints `UNPAIRED (no live twin)` and does not gate, so a merged paper book is visible even before its live twin exists.
Both the audit and `merge-paper-instance.sh` import the `-paper` suffix rule from one shared source, `scripts/paper_alias.py`, so the audit reads the same names the merge composes; each script refuses with exit 2 when that file is absent.
A folded source's alias carries its id (`<base>-paper-<id>`, with the same numeric-suffix rule), and the audit derives the suffix from the strategy's `paper_source`, so a source strategy pairs with its live twin instead of reading as unpaired.
A reserved or malformed source id yields no suffix at all rather than a wrong pairing.
Where `<dir>/go-trader` exists it reads cadence and sizing from `inspect --all --json` so root defaults compare as effective values; otherwise the row carries a `RAW` marker.
Twins that differ in platform, type, symbol or timeframe print `INCOMPATIBLE` and are left alone.

---

## Post-Update Agent Protocol

When invoked after an update (manual `git pull`, auto-update restart, "I just updated", "what changed"), walk the operator through anything new commits change on their existing config, strategies, and open positions, and prompt before applying any opt-in. The binary's own migration DM only covers a small registered field set; newer config-version bumps and opt-ins land silently unless an agent surfaces them.

### Trigger

Run when ANY of:

- The operator says "I updated", "I just pulled", "what's new", or asks about migration.
- `git log -1 --format=%cI` is newer than the running binary's version (`./go-trader --version`, or `curl -s localhost:8099/health` → `version`).
- `git status` is clean and `git rev-list --count <running-version>..HEAD` > 0.

### Steps

1. **Identify the diff.** `git log --oneline <running-version>..HEAD -- scheduler/ shared_scripts/ shared_strategies/ platforms/`. If the running version is unknown, ask the operator, or fall back to the last 30 commits.
2. **Classify** each commit against the table below.
3. **Read current state.** Load the config and query the state DB:
   ```sql
   SELECT strategy_id, symbol, quantity, side FROM positions WHERE quantity > 0;
   SELECT strategy_id, id, underlying, option_type, strike, expiry, action, quantity FROM option_positions WHERE quantity > 0;
   ```
4. **Prompt per item.** Default to no change if declined. For runtime defaults, also offer to write the explicit opt-out value.
5. **Apply through SIGHUP-safe edits** when the field supports it (see Reconfiguration); otherwise require a full restart.
6. **Verify.** Tail the logs for `[reload]`. On rejection, show the reason and offer a restart.

### Required prompt template

> Change: `<short description>`
> Affects: `<strategy IDs>` (and any open positions: `<symbol qty side>`)
> Default if you do nothing: `<what happens silently>`
> Options: 1) accept the new default, 2) opt out by setting `<field> = <value>`, 3) opt in to the new feature with `<field> = <value>` (requires flat? Y/N).
> Your choice?

Never apply a runtime-default change silently when the operator has not been shown the affected strategies. "Auto" means an automatic JSON rewrite, not an automatic behavior change.

### Reference: classification table

When in doubt, treat a commit as a runtime default and prompt. Per-release narrative for every archived entry lives in [`docs/POST_UPDATE_HISTORY.md`](docs/POST_UPDATE_HISTORY.md); regenerate a fresh candidate list from `git log --oneline -50`.

- **Operator-required circuit-breaker warning is throttled and clearable (#1824, runtime default, no opt-out, no config change, no new column).** When a live OKX spot strategy fires its circuit breaker (or a live Robinhood options strategy did, before #1823 made options paper only), the scheduler cannot close the venue position, so it keeps an operator-required pending entry and warns the operator.
  Before this change that warning posted to every channel and sent an owner DM on every cycle with due work until a portfolio kill switch fired; circuit-breaker expiry never cleared it.
  Now the warning repeats once per `alert_throttle_interval` (default 6h) and at once after a new contributing fire; the CRITICAL log line stays on every cycle. A fire with a flat own book adds no leg, and a later fire merges into the earlier entry instead of replacing it. Clear an entry with the new owner-DM command `/go-trader-clear-operator-close <strategy> [platform]` after you flatten it on the venue (§ Operator-Required Circuit Breakers); the command registers at startup.
  Existing entries send one warning on the first drain after the update and then follow the throttle. Legacy size-0 legs stay until you clear them. The new `episode` and `fired_at` keys live inside `risk_pending_circuit_closes_json`; an older binary ignores them.
  Go-only change; deploy with `bash scripts/update.sh --restart` as usual. After an update, tell operators with live OKX spot strategies or a saved `robinhood_options` entry; there is nothing to opt out of.
- **Options strategies run as paper only (#1823, runtime default, operator action required).** A `type: options` strategy with `--mode=live` or `--mode live` used to book simulated option trades in the live scope; no option order reached Robinhood, Deribit, IBKR or OKX, and its P&L fed live portfolio risk.
  Every config load (startup, SIGHUP, `probe`, `inspect`, `storage-inspect`, ledger export and every Discord and dashboard config write) now refuses it on every platform, with or without credentials, and `paper-to-live` refuses every options strategy.
  `check_options.py` refuses the same live forms with a JSON `error` and exit 1, and now reads its stdin positions when the args hold `--mode=paper` or `--mode paper` (before, either form dropped them, so the 4-position cap saw an empty book).
  Operator action: list each options strategy with a live mode, back up every state file, then remove it or give it a fresh `id` on `--mode=paper`; never keep the old `id` (split storage then exits 80, single-file storage resumes the phantom book in the paper scope and leaves the live peak unchanged).
  Full steps and effects: docs/POST_UPDATE_HISTORY.md, Runtime default, #1823.
- **Robinhood shared-coin circuit-breaker DM is throttled (#1817, runtime default, no opt-out, no config change).** When a live Robinhood strategy latches its circuit breaker (CB) on a coin that another live Robinhood strategy also trades, go-trader still sends no close for that coin and tells the owner by DM.
  Before this change that DM went out on every cycle until the CB cooldown ended (about 288 DMs for one 24-hour drawdown latch at a 5-minute cycle).
  Now the owner gets it once for each CB episode and coin, then again only after `alert_throttle_interval` (default 6h) while the CB stays latched; the CRITICAL log line stays on every cycle. A new CB episode notifies at once.
  The marker is stored per strategy in the new `strategies.risk_rh_shared_close_notice_json` column (idempotent migration), so a restart does not resend it; it clears when the CB unlatches, when the strategy's own book or the account is flat, when the coin has one live owner, and while the live kill switch fires. A failed save keeps the marker in memory, still sends the DM and retries with the cycle save.
  Go-only change; deploy with `bash scripts/update.sh --restart` as usual. An older binary ignores the column. After an update, tell operators with two or more live Robinhood strategies on one coin; there is nothing to opt out of.
- **Robinhood candles follow one timeframe table (#1822, runtime default, no opt-out).** Robinhood strategies now get Yahoo candles through one table (§ Robinhood candles): `2h`, `4h`, `6h`, `8h` and `12h` are built from Yahoo `1h` rows in UTC buckets, `1w` and `1M` map to `1wk` and `1mo`, and the Yahoo range follows `--ohlcv-limit`.
  Startup, SIGHUP and `probe` now refuse a Robinhood strategy whose `args` timeframe, HTF interval or `regime.timeframe` is not in the table, a `2h`, `6h`, `8h` or `12h` `args` timeframe or `regime.timeframe` for a symbol that is not a Yahoo crypto pair, and a `regime.timeframe` of `1M` when a Robinhood spot strategy uses the regime. Before updating, list every Robinhood strategy with its `args` timeframe, HTF (`htf_filter`) and `regime.timeframe`, and fix any refused value first; run `./go-trader probe --config <config>` to check.
  Effects: a Robinhood `1h` or `30m` strategy with `htf_filter` that held every entry since #1826 (its `4h` HTF had no rows) now filters on a real `4h` trend; `60m`, `1wk` and `1mo` strategies get more history (`60m` had a 5-day range), so paper results before and after are not comparable; an unsupported timeframe in the chart shows an error instead of an empty chart. The Robinhood options regime keeps its `4h` Yahoo series for stock underlyings.
  `closed_bar_decisions` is now accepted on Robinhood **paper** spot (opt-in, with the #1769 entry allowance, `5m` to `1d`, with a 20 s Yahoo settle hold after each boundary) and stays refused on live Robinhood, on `1m` and for a symbol that is not a Yahoo crypto pair (§ Closed-Bar Decisions). Go and Python change together, so deploy only with `bash scripts/update.sh --restart`.
- **Missing higher-timeframe input holds entries (#1826, runtime default, no opt-out, no config change).** A strategy with `htf_filter: true` (Hyperliquid perps, OKX spot and perps, Binance.US spot, Robinhood, TopStep) now holds new positions and additions whenever its higher-timeframe (HTF) candles are missing, fewer than 50, unreadable, non-finite or failed to fetch.
  Before this change the filter returned a neutral trend for that input, a neutral trend passes every signal, and the entry opened unfiltered with no warning.
  Each successful check now returns an `htf_filter` block (`ok`, `unavailable`, `skipped` or `not_evaluated`, with timeframe, trend and reason); a missing block or an unknown status is a script error (§ Higher-timeframe filter).
  A successful neutral trend still passes its signal. Closes, partial closes, a spot sell that reduces a long, stops, ratchet and protection continue while the input is unavailable.
  Repeated unavailable evaluations send an **HTF FILTER UNAVAILABLE** alert to the strategy's partition channels and the owner (3 evaluations, or 15 evaluations or 75 minutes for upstream 429/5xx errors), with the usual repeat throttle; the first successful evaluation after an alert sends one **HTF FILTER RECOVERED** message. The count is held in memory and starts at zero after a restart.
  Backtests (`run_backtest.py --htf-filter`) and the `/tuning` simulator first top up a short or non-covering HTF cache from the exchange, then refuse an HTF cache that is still empty, shorter than 50 rows, or not covering the requested range (`HTF FILTER REFUSED`) and save no result; before, they ran unfiltered or on a stale trend. The simulator's filtered path also works again with a valid cache (it failed on a time-zone mismatch).
  The Go result contract and the check scripts change together, so deploy only with `bash scripts/update.sh --restart`. This change does not depend on #1822 (Robinhood timeframe mapping); a Robinhood or TopStep strategy whose HTF interval the provider does not serve now holds entries and alerts instead of trading unfiltered.
  After an update, list every strategy with `htf_filter: true`, tell the operator that unavailable HTF input now holds its entries, and point to the `HTF filter: ... signal suppressed` log line and the alert; do not turn the filter off on the operator's behalf.
- **Egress proxy mode (#1841, opt-in, nothing changes unless `GO_TRADER_EGRESS_PROXY` is set).** A deployment can send every external connection through one proxy behind a root-owned egress controller that blocks every direct route (§ Egress Proxy Mode).
  With the variable unset, transports, child arguments and Python behavior are unchanged; `/status` and the feed `/status` gain an `egress` block that reads `{"mode": "disabled"}`.
  The Python environment gains `requests[socks]` (PySocks), and `shared_scripts/llm_review.py` now calls the API through a requests session instead of urllib (same response parsing and stdout contract; transport error names in the advisory `error` text can differ).
  An `egress` config key other than `proxy_required` or `notifications_direct`, or a non-boolean value, now fails the load, and a SIGHUP that changes the block is refused.
  Deploy only with `bash scripts/update.sh --restart` (Go and Python change together).
  After an update, tell the operator the mode exists and is off; do not turn it on without consent, because it needs the installer, a reachable proxy, and Linux with systemd 247+ or rootful Docker.
- **Robinhood logins keep one owner-only session beside the config (#1818, runtime default, no opt-out, no config change).** Every Robinhood login now stores and reuses its session only in `<config folder>/robinhood-session` (Secrets, Robinhood session), which go-trader sets at startup; the scripts no longer read or write `$HOME/.tokens`.
  Before this change a shipped unit with home `/nonexistent` failed every live Robinhood login, and a host that followed the #1816 HOME step sent a new password login with a new random device token whenever the cache could not be used.
  A legacy `$HOME/.tokens/robinhood.pickle` is not read, so the first login after the update uses a new device identity and can need the one-time bootstrap (`go-trader robinhood-login --config <config.json>`); a login that meets a device challenge now fails at once and stays latched until the bootstrap succeeds, and it never waits for a prompt.
  Operators can delete the old `$HOME/.tokens` cache after the bootstrap; the `HOME=` line that #1816 told them to add can stay for other per-user caches such as the yfinance cache, though Robinhood no longer uses it.
  The new `robinhood-login` subcommand and `GO_TRADER_ROBINHOOD_SESSION_DIR` change no probe argv, and `scripts/update.sh` keeps a `robinhood-session/` folder out of its rsync delete.
  After an update, tell operators with live or paper Robinhood strategies, show them the bootstrap command for their layout, and confirm the `[robinhood] session folder` startup line and that later cycles show no `Robinhood login failed`.
- **Robinhood emergency closes sell only the go-trader book (#1825, runtime default, no opt-out, no config change).** Before this change the kill switch and the circuit-breaker (CB) close sold the whole account balance of a coin, also coin the operator holds by hand in the same Robinhood account.
  Now each sells at most the remaining go-trader book for the coin (the kill switch sums every live Robinhood spot book; the CB uses the latched strategy's own book), capped by `quantity_available` through the same exact-decimal sizing as #1819 with close kind `emergency`, which never sweeps dust.
  Holdings above the book are the operator's: they are logged as CRITICAL and named in the kill-switch message, the reset DM and a CB owner DM, and are never sold. A coin that no configured strategy trades stops blocking kill-switch confirmation only when no Robinhood book (configured or not) holds it and no order for it is unresolved.
  A CB trip sets its pending close from the book at fire time (`size_basis: book`) and binds it to that position's `position_id`; a flat book sets none and later cycles send nothing, also while the account holds the coin. When that position is gone and the strategy holds a newer one (for example after the cooldown ended), the pending close is cleared and nothing is sold. A partial fill, a failed holdings read or an unsaved settlement keeps the pending close, and the next cycle asks only the unsold remainder, also after a restart.
  A coin counts as closed only when no order for it is unresolved after settlement, the settlement save succeeded, a strict holdings read succeeded, and the remaining book is at or below tolerance; or when that read shows no holding at all outside the 10-minute lag window, and the verified-flat cleanup closes the leftover book. `quantity_available = 0` alone never proves flat.
  Reserved holdings, an account shortfall and a remainder below the venue minimum or one increment hold the coin unconfirmed with the reason in the kill-switch message or the CB drain-failure DM; the operator sells by hand, resets the kill switch, or removes the strategy.
  A pending close from an earlier version (no `size_basis`) never sells above the book: while the breaker is still latched and the book is long it becomes a book-sized close bound to that position; with a flat book, or once the breaker is no longer latched, it sends nothing, sends one CRITICAL owner DM and clears. A fill above every book books what fits and sends one **ROBINHOOD SALE ABOVE THE BOOK** owner DM.
  Emergency sells freeze request version 3. The close script argv changed (required `--quantity`; a missing or invalid value or any argument error exits 1 with one JSON document and no login), so deploy only with `bash scripts/update.sh --restart`. Do not roll back while a Robinhood CB pending close or a version 3 attempt exists: an older binary cannot read version 3 and sells the account quantity.
  After an update, list every live Robinhood strategy and its open positions, and tell the operator that hand-held coin in the same account is no longer sold and is reported instead.
- **A failed Robinhood login stops live Robinhood work (#1816, runtime default, no opt-out, no config change).** A Robinhood login now succeeds only when robin_stocks returns a token, sets its login state, and sets the matching `Authorization` header.
  In live mode a failed login ends the signal check, execute, emergency close, position fetch, live strategy chart and balance check with an `error` that starts `Robinhood login failed:` and names the failed check, so there is no signal and no order, the kill switch stays unconfirmed, and the circuit-breaker pending close is kept until a login succeeds.
  Before this change the adapter stayed marked live after a failed login, signal checks ran on Yahoo prices, and orders failed with a library "can only be called when logged in" error.
  In paper mode a failed login leaves the adapter logged out, and it uses the public price and option fallbacks with no Robinhood call.
  robin_stocks login lines on stderr now pass through an allowlist with a `[robinhood] robin_stocks:` prefix, and other login text shows as `(detail withheld)`, so credentials, tokens and paths stay out of logs.
  The Robinhood token cache and HOME steps of this entry are replaced by the #1818 entry above (Secrets, Robinhood session).
  A `HOME=` line set for this entry can stay, because other per-user caches of the service, for example the yfinance cache, go under that HOME.
  After an update, tell operators with live or paper Robinhood strategies; there is nothing to opt out of.
- **Robinhood live sells use the account's sellable quantity (#1819, runtime default, no opt-out, no config change).** A live Robinhood signal sell now reads the coin's `quantity_available` and the pair's `min_order_size`, `min_order_quantity_increment` and `max_order_size` in the order subprocess, on the same login and under the coin lock, before it sends.
  It sends `min(asked, available)` floored to the increment with exact decimal math, and refuses with `not_sent`, no order and a **LIVE ORDER FAILED** alert when a read fails, a field is missing or invalid, nothing is available, or the result is below the minimum or above the maximum.
  Only a whole close by the sole configured live owner of the coin sells sub-minimum dust above the book (`0 < available - asked < min_order_size`); a peer strategy or a partial close never sweeps, and a whole close that leaves at least the minimum reports the residue at WARN.
  The book changes only by the confirmed fill and keeps every remainder; a residue below one increment closes at zero proceeds when the account held the full ask and the order filled completely.
  When the account holds less than the ask, the book keeps the unbacked part and a once-per-attempt **ROBINHOOD BOOK SHORTFALL** DM names the asked, available, sent and filled quantities; there is no automatic write-off.
  Signal sells are frozen as request version 2 with write-once `sizing` evidence, and recovery searches for the sent quantity, never the ask.
  If the live API omits `quantity_available` or a pair rule, every live signal sell refuses with a LIVE ORDER FAILED alert that names the field; since #1825 the kill-switch and circuit-breaker closes use the same rules with close kind `emergency` and refuse the same way.
  The order argv changed (`--quantity` text, `--close-kind`, `--sole-owner`), so deploy only with `bash scripts/update.sh --restart`.
  The Robinhood, OKX and TopStep signal scripts now accept the startup probe's `--basket-member` flag and refuse it at run time; before this, `go-trader probe` and the update probe failed for any config that runs one of those scripts (a #1781 gap).
  After an update, list every live Robinhood strategy with open positions and tell the operator that a position booked before #1815 can raise a shortfall alert on its first close.
- **Live Robinhood strategies start trading (#1814, runtime default, no opt-out, no config change).** Before this change, when `ROBINHOOD_USERNAME`, `ROBINHOOD_PASSWORD` and `ROBINHOOD_TOTP_SECRET` were set, robin_stocks login text went to stdout and every Robinhood script result failed to parse: Robinhood strategies got no signals, live ones placed no orders, and the kill switch and circuit-breaker close never sold Robinhood coin.
  Now each Robinhood script writes one JSON document, so with credentials set, live Robinhood strategies place real orders and the kill switch and circuit-breaker close sell.
  The live sell size defect is fixed: a live signal sell sends at most the account's sellable quantity (#1819).
  The emergency close size defect is fixed: the kill switch and circuit-breaker close sell at most the go-trader book and report holdings above it (#1825).
  After an update, use the required prompt template to list every live Robinhood strategy (`platform: robinhood` with `--mode=live` in `args`) and its open positions.
  To stop orders for a strategy: `paused: true` (hot-reloadable) stops new buys, but signal closes, the kill switch and the circuit-breaker close still sell its book; removing the strategy from the config (restart required; read its open positions first) stops all Robinhood orders for it.
  With no live Robinhood strategy, there is nothing to change; tell the operator that paper Robinhood strategies with credentials set now get signals again, so their paper results before and after this update are not comparable.
  This entry is the warning, because the update notice shows only commit titles.
- **Robinhood live orders book only confirmed venue fills (#1815, runtime default, no opt-out, no config change).** Every live Robinhood order (signal, circuit-breaker close, kill-switch close) is first saved in `robinhood_order_attempts` with a go-trader `ref_id`.
  The order script then polls the order to a final state within one budget, cancels it at the deadline and reports `order_outcome`.
  Only a confirmed fill (finite price and quantity above 0, an order id) changes the book, at the venue price and quantity, once; a partial sell keeps the unsold remainder.
  A missing, unknown, still-open or contradictory result books nothing and holds the coin: no Robinhood order for that coin is sent until recovery (every cycle) or `/resolve-robinhood-order` resolves it, with a **ROBINHOOD ORDER UNRESOLVED** CRITICAL DM.
  Circuit-breaker activation no longer clears a live Robinhood book at the mark; the circuit-breaker and kill-switch closes count a coin as closed only after settlement and a fresh strict holdings read prove the book sold (#1825 rule), and close a leftover book quantity with a `*_verified_flat` row only when that read shows no holding at all.
  A flat read within 10 minutes of the buy that opened a live Robinhood long does not confirm flat, because the holdings read can lag the fill; the close retries next cycle.
  A fill split across strategies that share a coin commits only with a full live partition save, so a per-strategy save cannot store part of it.
  Startup exits 80 when an unresolved attempt belongs to no configured live Robinhood spot strategy; `storage-inspect` lists every unresolved attempt.
  The Python order argv changed (`--ref-id`, `--budget-ms`), so deploy only with `bash scripts/update.sh --restart`.
  After an update, tell operators with live Robinhood strategies, run `./go-trader storage-inspect`, and name each unresolved attempt.
- **Robinhood position reads fail closed (#1813, runtime default, no opt-out, no config change).** The Robinhood position fetch and emergency close now read every holdings page with bounded requests and check each row. An incomplete or invalid read (HTTP error, failed later page, bad pagination, missing symbol or quantity, library not logged in) now exits 1 with an `error`, so the kill switch stays unconfirmed and the circuit-breaker pending close is kept, with a CRITICAL `rh-close` or `rh-circuit-close` log line each cycle until a read succeeds. Before this change, such a read could report a held coin as already flat. After an update, tell operators with live Robinhood strategies; there is nothing to opt out of.
- **Ratchet owner DM reports the booked stop (#1725, runtime default).** The trailing-ratchet owner DM now shows the stop recorded after the same-cycle trailing update, and it says when the venue result is unknown. Stops and orders are unchanged. No config change; `notify_ratchet_triggers: false` still disables the DM.
- **Docker image and Compose setup (#1734, internal for host deployments).** systemd and Linux process deployments keep their update, restart, bind and storage behavior. The new daemon flag `--status-bind` (and `GO_TRADER_STATUS_BIND`) accepts only loopback on a host, so a host config needs no change. Mention `docs/DOCKER.md` to an operator who wants a container; apply nothing.
- **`resting_tp_trade_through` (#1727, new opt-in field).** Dormant until set; with it off nothing changes and live is never affected. After an update, mention it and, only if the operator wants paper tier take-profits that need a one-tick trade-through instead of a mark touch, offer it on a new paper Hyperliquid perps strategy ID (paper tier results before and after are not comparable). Warn before the restart if a config names a custom check script, because the probe now sends `--resting-tp-rule-json`, and update a `market_feed: shared` feed service in the same release.
- **Live entry absence proof and `/resolve-entry` (#1771, runtime default for live `closed_bar_decisions` strategies, no opt-out).** Releasing a held live entry attempt because the venue shows no order now needs the saved venue account to match the queried account and a complete, successful fill-history check; a missing order alone, a flat position or an incomplete history keeps it held. New attempts save a non-secret account key (Hyperliquid network plus address; OKX network plus a SHA-256 fingerprint of the API key). After the update, run `./go-trader storage-inspect` and name each unresolved `[entry]` attempt saved before the update: it has no account key, so only a terminal order or a proven fill can resolve it, and `/resolve-entry` refuses it. Mention the new owner DM command. Deploy with `bash scripts/update.sh --restart`: the recovery probes now send `--since-ms` (OKX) and `--until-ms` (both), so a custom check script needs the flags.
- **Closed-bar entry allowance (#1769, runtime default for `closed_bar_decisions` strategies, no opt-out).** A strategy with `closed_bar_decisions: true` now takes at most one entry per decision candle and side, holds a reversal's opening leg until a later check from a flat book, and holds new live entries while an attempt's venue outcome is unresolved. After the update, list every `closed_bar_decisions` strategy, tell the operator each pre-existing one holds the first candle it observes (one candle of entries) and that paper results before and after are not comparable, and check the start log and `./go-trader storage-inspect` for `[entry]` unresolved lines. New state tables migrate on start; deploy with `bash scripts/update.sh --restart` (the probe now sends the new execute and recovery flags).
- **`closed_bar_decisions` (#1712, new opt-in field).** Dormant until set; with it off nothing changes. After an update, mention it and, only if the operator wants backtest-aligned decisions, offer it on a new paper strategy ID (entries can start up to one bar later). Warn before the restart if a config names a custom check script, because the probe now sends `--closed-bar-decisions` and `--decision-regime-timeframe`, and update a `market_feed: shared` feed service in the same release.
- **Hyperliquid perps `--config` backtests (#1728, research results only).** Live and paper trading are unchanged. After an update, tell the operator that a new `--config` backtest of every Hyperliquid perps strategy changes, including leverage 1 with no `margin_per_trade_usd` (fee on top of notional). It is not comparable with an older `--config` result for that strategy. `momentum_pro` with `vol_target_atr_pct` > 0 now refuses, because that setting emits `entry_fraction` and the live sizer has no such input. A backtest that does not load a config is unchanged. No config edit and no restart.
- **Paper Hyperliquid perps book venue lot sizes (#1716, runtime default, no opt-out).** Paper HL perps entries, flips, scale-in adds and partial closes (tier fills included) now floor to the coin's `szDecimals`, and hold with no book write when the floored order is 0 or below $10.30 or when the lot size is unknown. Full closes, stops and kill-switch flattening are unchanged. After an update, list every paper HL perps strategy and its open positions, tell the operator that paper results before and after this update are not comparable (recommend a new paper strategy ID when a clean comparison matters), and check the start log for `[hl-lot] ... unavailable` lines. No config, `state.db` or Python change; deploy with `bash scripts/update.sh --restart`.
- **Args parameter flags warn at load when `--strategy-refs` supersedes them (#1711, runtime default with no behavior shift).** After an update, run `./go-trader inspect --all` (or read the start log), list each strategy that shows the new `[WARN]` with the unused args entry, and prompt per strategy: delete the entry (trading unchanged), or move the value into `open_strategy.params` or the matching field (trading changes, so recommend a new paper strategy ID). Apply an approved edit only with a restart, because an args change blocks SIGHUP hot reload. Default if the operator declines: nothing changes, and the warning repeats on each start.

| Category | How to recognize it | What to do |
| --- | --- | --- |
| Auto-migration | `CurrentConfigVersion` bumped; the loader rewrites the JSON on next start | Summarize. No prompt. Warn that a rewrite replaces a still-symlinked `scheduler/config.json` with a regular file |
| Runtime default | Behavior shifts on existing strategies with no config edit | Prompt: confirm, or write the explicit opt-out |
| New opt-in field | The feature stays dormant until the field is set | Prompt per affected strategy |
| Open-position constraint | The change needs flat positions to apply | List affected strategies, warn, and skip until flat |
| Internal / no-op | Refactors, tests, docs, dashboard and formatting work | Mention briefly |

**Config-version floor.** `CurrentConfigVersion` is 20 and `MinSupportedConfigVersion` is 13 (`scheduler/config_migration.go`; `config_migration_test.go` pins the floor). A stamped `config_version` below that fails loudly at load instead of migrating. Run `scripts/check-config-versions.sh` and confirm the whole fleet is at or above the floor before raising it again.

**Opt-in fields** stay dormant until set. Adjustable Settings is the complete list, with each shape, default, and reload rule.

**Fields blocked while a position is open** (flatten first, or restart after the close):

- `margin_mode` and exchange `leverage`
- kill-switch identity changes
- `stop_loss_atr_mult` / `trailing_stop_atr_mult` nil↔positive toggles, and any scalar↔regime stop flip
- `invert_signal`
- `regime_*_window` selectors, `regime_directional_policy`, `regime_window_divergence`, `regime_profile_allocation` shape changes
- `hedge` add, remove, or shape change (either leg open)
- `replay_sharing` toggle and `replay_source_id` changes (the paper book would desync from the log mid-trade)
- `atr_method`
- `allow_scale_in` and the `scale_in` block
- the unified per-regime close block, and any ratchet tier table

---

## Status

Default port `8099`. Override with `--status-port <port>` or `status_port` in config. If the port is busy the server tries the next five; the log names the one it took. The container binds `8099` exactly and refuses any other port (§ Docker Container).

```bash
curl -s localhost:8099/status | python3 -m json.tool
curl -s localhost:8099/health
curl -s localhost:8099/history
open http://localhost:8099/dashboard
```

`/health` returns 503 while the daemon drains, and when the main loop misses its own schedule: a pass of the loop (a cycle, reload or off-cycle audit) still running 30 minutes after it started, startup not reaching the loop within 30 minutes, or no wake 5 minutes after the time the scheduler planned. The age of the last cycle alone never makes it unhealthy, so a long `interval_seconds` stays healthy between cycles. Discord `/health` uses the same rule.

`/health` also carries `run_evidence`: the process start time, the time each strategy was last evaluated (`evaluated`), the strategies skipped at zero capital (`zero_capital_skipped`), the strategies a cycle held back with the reason and time (`held`: `portfolio_kill_switch` for a latched or fired scope kill switch, `save_blocked` after three failed saves), and `last_state_save`, the end of the last cycle whose save succeeded for every partition.
A hold replaces that strategy's `evaluated` time, and an evaluation replaces that strategy's hold.
At the end of a cycle every due strategy is recorded held or cleared.
A hold on a strategy that is not due is cleared on the next cycle whose partition is neither latched nor save-blocked, whether or not that strategy runs.
Every map is empty after a restart. When `STATUS_AUTH_TOKEN` is set, `/health` includes `run_evidence` only for a request with `Authorization: Bearer <token>`; `status`, `version` and `pid` stay open for monitors. `scripts/migrate-service-layout.py` uses it as its execution proof and sends the token read from the running process's environment (never written to its manifest or journal).

Dashboard JSON endpoints: `/api/strategies`, `/api/strategies/overview`, `/api/strategies/<id>/(candles|trades|status|equity|config|simulate)`, `/api/regime/transitions`, `/api/tuning/runs[/<id>]`. Candles and equity are cached 30 seconds. `config` (GET) and `simulate`/`config` (POST) need `status_token` plus a same-origin header; when `status_token` is set the dashboard page prompts for it and keeps it in browser local storage.

**Never expose the status port publicly.** On a host the server listens on loopback only: `--status-bind <host>` (or `GO_TRADER_STATUS_BIND`, default `localhost`; the flag wins) accepts only `localhost`, `127.0.0.0/8` or `::1`, and any other value stops the start. Do not rebind a host to `0.0.0.0`.
Only the Docker image (`GO_TRADER_RUNTIME=container`) may bind the wildcard address, and only with `STATUS_AUTH_TOKEN` set; Compose then publishes the port on host `127.0.0.1` only (§ Docker Container). Front each instance with [Tailscale Serve](https://tailscale.com/kb/1242/tailscale-serve) or another authenticated proxy on the same machine — for example `tailscale serve --bg --https=8443 http://127.0.0.1:8099`, then browse `https://<node>.tailnet.ts.net:8443/dashboard`. A common multi-instance port map (match each `status_port`): live `8099`, paper-testing `8100`, then `8101`+ per paper instance. After a paper instance is folded into the combined paper service (§ Storage Ownership), its port and its proxy or tunnel mapping are retired; the combined paper service's port serves every folded partition. Paper and live never share a service. An agent stack such as OpenClaw may serve its own dashboard on other ports; that UI is not go-trader's.

The dashboard also carries mutating controls behind a typed-confirmation nonce: pause/unpause and ratchet-notification toggles, trade actions (close, manual edits), and structural mutations (add/remove strategy, paper-to-live, apply-regime-gate).

`/tuning` is a read-and-launch research page for suggest-only per-strategy retunes. It re-reads live config on every poll and never writes config itself; `POST /api/tuning/apply` is the only promotion path.

If Discord is enabled, wait for the first cycle and confirm messages in the configured channels. Report success with mode, strategy count, status URL, and the log command.

---

## Discord Slash Commands

Global slash commands register at startup, covering every guild the bot is in plus DMs; a first-time command-shape change can take about an hour to propagate. The bot must be invited with the `applications.commands` OAuth scope in addition to `bot`. Every command carries the `go-trader-` prefix on the wire (`/go-trader-status`); authorization and dispatch operate on the bare ID. Registration failure is non-fatal — it logs and DMs the owner.

**Read-only** — any guild or DM, anyone. They read live in-process state with no HTTP round trip. Replies are public in-channel unless `discord.ephemeral_replies: true`.

`/go-trader-status`, `/go-trader-health`, `/go-trader-positions`, `/go-trader-pnl`, `/go-trader-leaderboard [top]`, `/go-trader-circuit-breakers`, `/go-trader-dead-strategies`, `/go-trader-correlation`, `/go-trader-closing-strategies`. The four that fetch live marks (`status`, `positions`, `pnl`, `leaderboard`) defer the ACK so they do not blow Discord's 3-second deadline.
`scripts/post-paper-leaderboards.py --port <paper status_port> --channel <Discord channel id> [--top N] [--dry-run]` posts the cross-coin paper leaderboard to Discord from the paper service's status API, reading `DISCORD_BOT_TOKEN` (and `GO_TRADER_STATUS_TOKEN` when the status server has a token); a 10-minute sentinel file dedupes repeat posts.

**Ops and mutating ops** — owner-only AND DM-only:

| Command | What it does |
| --- | --- |
| `logs [n]` | The last N journal lines of the unit (`GO_TRADER_SERVICE`, default `go-trader`). It reads the unit's `LogNamespace` from `systemctl show` and runs `journalctl --namespace=+<ns>`; an empty value or a failed lookup reads the default journal. In the container image it runs neither and replies with the `docker compose logs` command. DM-only because logs can carry wallet addresses and error payloads |
| `restart` | `systemctl restart go-trader`; it ACKs, then this instance is replaced. In the container image the daemon drains, saves state and exits 0, and Docker starts the container again |
| `backtest <strategy> <symbol> [timeframe]` | A single-mode backtest, 5-minute timeout, holding one of the four Python semaphore slots; replies with a summary and attaches the report |
| `report-an-issue <title> <body> [label]` | Files a GitHub issue against `discord.report_repo` (default `richkuo/go-trader`). Token from `GO_TRADER_GITHUB_TOKEN`, then `GITHUB_TOKEN`, then `discord.report_github_token`; it says so when none is set |
| `config show` | The running config with secrets redacted |
| `config set <key> <value>` | A top-level or per-strategy field. Per-strategy keys (`strategies.<id>.<field>`) need `config_version` 13 or newer. Writes are atomic and serialized with the dashboard tuner; the reply states whether it applied through SIGHUP or a restart |
| `add-strategy <name> <platform> <asset>` | Generates a Hyperliquid perps (always paper) or BinanceUS spot entry. The name must be a known short name. An entry-only candidate listed in `addStrategyEntryOnlyCandidates` also gets its explicit `close_strategy` (with tier params where listed) and `closed_bar_decisions: true`, so it decides on the closed bars its study used, plus `stop_loss_atr_mult` where listed (for example `mass_index_reversal`: `tiered_tp_pct` +3%, `stop_loss_atr_mult: 1.0`); otherwise the stop stays the loader default |
| `remove-strategy <id>` | Removes a strategy after an out-of-band DM confirm. Needs a restart |
| `add-platform <name>` | Emits a setup checklist. Secrets go in the environment file, never the config |
| `paper-to-live <strategy>` | Flips `--mode=paper` or `--mode paper` to one `--mode=live` token after a DM confirm. Needs a restart, refuses while the strategy holds an open position, and refuses every `type: options` strategy (options run as paper only) |
| `apply-regime-gate` | Interactive picker over type-eligible flat strategies, applies a named regime entry-gate preset, then confirms before writing. It refuses a non-flat target both before and after the confirm. The confirm lists any OTHER strategy whose dormant `allowed_regimes` gate the accompanying `regime.enabled` flip would reactivate — read that list first. Applies through a full restart |
| `clear-cash-reconcile <strategy>` | Clears the cash-reconcile latch after you confirm virtual cash matches the venue. It never invents or adjusts cash; it only drops the block on live spot buys |
| `clear-operator-close <strategy> [platform]` | Clears one operator-required circuit-breaker entry (`okx_spot` or `robinhood_options`) after you flatten it on the venue (§ Operator-Required Circuit Breakers). It refuses every automatic pending close, an unknown platform, and an omitted platform when the strategy holds both keys. The DM `confirm` prompt shows the strategy, platform, episode and legs; after the confirm it checks them again under the state lock and refuses when a new fire or another clear changed the entry. A failed save restores the entry and refuses. It does not read the venue or change cash, trades, positions or the circuit breaker |
| `resolve-robinhood-order <strategy> <attempt> [order] [release:attest-no-order]` | Runs a fresh venue check for one held live Robinhood order attempt (`rh-…` id from the alert, the start log or `storage-inspect`). The optional order id is only a lookup key: the order must carry the attempt's `ref_id`, or else match its side, pair, quantity, submit window and account; for a sized sell the quantity is the saved sent quantity, and with no saved sent quantity no field match is tried, so the check never proves that no order exists while a same-pair order without `ref_id` is in the window (#1819). A proven fill moves the attempt to `settling` (the next cycle books it); a proven no-fill releases it. `release:attest-no-order` is the only release without venue proof: it applies only to a `submitting`/`unknown` attempt with no venue order id after its settle window, runs the venue check first, then shows the side, size, `ref_id` and submit window and needs a DM `confirm`; it records `no_fill` with the operator id, prior status, hold reason and evidence. Use it only when the order list cannot be read to its end (more than 20 pages) and the Robinhood order history shows no order. It never sends, cancels or books an order |
| `resolve-pair-native <partition> <pair> <member> <decision> [kind] [id] [revision] [reason] [orders]` | Reviews or resolves one native pair close obligation (§ Native fill resolution (#1927)). `decision:show` lists the member's open `native_fill_unknown`, `native_fill_unconfirmed`, `native_receipt_conflict`, `native_receipt_ambiguous` and `native_legacy_unverified` obligations with their current revision, attempt order ids and decisions. A change needs kind, id, the current revision and a reason, and a DM `confirm`: `venue-read` (unknown and unconfirmed only; optional comma-separated venue order ids of this close) reads the venue history once and books only proven fills through the settlement ledger; `write-off` records an unbooked gap and keeps funding incomplete from the unknown start; `acknowledge` (conflict, ambiguous and legacy only) records that the existing books stand. The handler only stages the decision in the pair progress; the coordinator step applies it once and sends one CRITICAL alert after the commit. It refuses a stale revision, a wrong partition, an unknown or resolved obligation, `native_receipt_unattached`, `emergency_close_pending`, a refused pair and a member with a native close or venue read in flight. It never sends an order, asserts venue flatness or clears the kill switch |
| `resolve-entry <strategy> <attempt>` | Runs a fresh venue check for one held live entry attempt (`ei-…` id from the alert, the start log or `storage-inspect`). It releases a `submitting` attempt as `no_fill` only on complete no-execution evidence and refuses everything else (§ Entry allowance, Operator release). It never sends, cancels or books an order |

Every config write serializes on one mutex, and mutating commands restart deployment-agnostically (systemctl with `GO_TRADER_SERVICE`, falling back to an in-process exec for signal-mode deploys; the container image uses neither and restarts by drain, state save and exit).

---

## TradingView Export

```bash
./go-trader export tradingview --strategy hl-btc-momentum --output tv-hl-btc.csv
./go-trader export tradingview --strategy hl-btc-momentum --strategy okx-eth-breakout --output tv-selected.csv
./go-trader export tradingview --all --output tv-all.csv
```

Ask which strategy IDs (or all) before running. CSV header, symbol mappings, and `tradingview_export.symbol_overrides`: [README.md](README.md) § TradingView Export.

---

## Booked-Ledger Export

The reconciliation input (issue 1685; the offline comparison is issue 1686). Two steps, both one-shot commands outside normal scheduler execution. Neither needs trading secrets, starts a notifier or Python, fetches venue data, takes the scheduler's ownership or manual-action lock, or opens migrating storage; the only lock on a source is the read lock any SQLite reader takes. Configuration loads through `LoadConfigForLedgerExport` (live credential checks off, migration in memory only), so a configuration that needs migration is never rewritten.

```bash
./go-trader export capture --config /var/lib/go-trader/config.json --output-dir /var/tmp/ledger-snap-20261005
./go-trader export ledger --manifest /var/tmp/ledger-snap-20261005/capture.json \
  --partition live --strategy hl-btc-momentum --output /var/tmp/hl-btc-momentum.ledger.json
```

**Capture.** Linux only.
The capture worker runs in a private mount namespace (root with `CAP_SYS_ADMIN`, or an unprivileged user namespace) that bind-mounts every canonical state directory read-only over itself, checks with `statfs` that each directory, state file and sidecar is on a read-only mount, then opens each file with `file:<path>?mode=ro&cache=private&readonly_shm=1` (one pinned connection; only `busy_timeout=5000` and `temp_store=MEMORY`) and runs `VACUUM INTO` a new file in the output directory.
The worker checks its own namespace before any mount: its mount namespace must differ from its parent's, or, when the parent's is unreadable (unprivileged user namespace), be owned by its own non-initial user namespace; plan input never vouches for it.
The running scheduler stays outside that namespace. macOS, or a Linux host that blocks both namespace paths, refuses capture before creating anything; source permissions are never changed to make it work.
Refused sources: a missing configured file (named apart from other status errors), a hard-linked state file, a symlinked or hard-linked sidecar (a symlinked configured path resolves to its target, and capture protects the target's directory), a rollback journal beside a file, a WAL-mode file with no `-wal` (no live connection, for example a stopped scheduler), `-wal` without `-shm`, and `-wal`/`-shm` that no live SQLite connection holds (WAL recovery).
So capture a WAL-mode deployment while its scheduler runs.
Each file is one transaction (`transactional_per_file`); separate files are separate snapshots, and the manifest records each file's capture interval.
The worker digests `trades`, `wallet_transfers` and `trade_diagnostics` before and after `VACUUM INTO` (up to 3 attempts while they change) and requires the snapshot to match, so row identifiers and content are preserved; the snapshot must pass `PRAGMA integrity_check` = `ok` and be rollback (`delete`) mode with no sidecar.
The output directory must be new and outside every state and configuration directory.
The set is `state/<role>.db` files, `config.json` (the source configuration with the storage paths changed to relative snapshot paths and `discord.token`, `discord.report_github_token` and `telegram.bot_token` removed; every other setting is checked equal) and `capture.json` (manifest version 1, written last: source and snapshot paths, hashes, capture method, confinement, driver and SQLite version, per-file intervals, source configuration hash).
A failed capture removes only what it created.

**Export.** `--manifest`, `--partition`, `--strategy` and `--output` each exactly once; duplicates, extra arguments, empty values, `--all` and `--config` exit 2.
The manifest must be named `capture.json`; its directory may move.
Before any read: manifest version, clean contained relative paths, the exact inventory (any extra file, any `-wal`/`-shm`/`-journal` even empty, symlink or hard link is refused), every hash, and a rollback-mode SQLite header.
Snapshot files open `mode=ro&immutable=1&cache=private` with `query_only=ON`.
The existing ownership inspection must report no rejection, the strategy must match its partition through `partitionFor` and the identity map, be Hyperliquid perps or Hyperliquid manual, and have its stored `strategies` row in its own file (unsplit layouts included).
Mandatory `trades` accounting columns missing = refused (no migration runs); invalid or empty timestamps, malformed Booleans, corrupt TP JSON, text or non-finite amounts fail the whole export.
The output must not exist (symlinks and hard links included), must not use a sidecar name, and must sit outside the snapshot, state and configuration directories; it is written to a private staging file beside it and linked into place without replacement only after every input is re-verified.
A killed export leaves no output, at most a `.<name>.<hex>.staging` file.

**Contract (schema `go-trader.booked-ledger`, `schema_version` 2).** Required top-level fields: `inspected_revision` (the binary's source commit from `-X main.SourceCommit`, which `update.sh` stamps from the exported commit; else `null`, never the version label), `capture_manifest_sha256`, `time_basis` `UTC`, `timestamp_meanings`, `selection`, `capture`, `snapshot_files`, `current_effective_configuration` (basis `current_at_capture`: selected strategy, `regime`, partition `portfolio_risk`, and the optional `atr_method` object with the strategy value, the root value and the method `resolveATRMethod` resolves; no tokens or credentials), `events` and `wallet_orphan_context`.
Events are the selected strategy's every `trades` row in `rowid` order (keyset pages of 500 in one transaction); `event_key` = `<source_role>/trades/<rowid>`, unique only within its manifest.
Every data field is `{value, raw_value, status, reason, provenance[]}`: status `available`/`unavailable`/`not_applicable`, reason `column_absent`/`stored_null`/`unstamped`/`not_recorded`/`ambiguous_evidence`/`not_applicable`.
Stored amounts, gross flags, fees (zero and negative kept) and identifiers are copied, never recomputed; order ids are decimal strings.
`row_net_pnl` = `tradeNetPnL`, `ledger_delta` = `tradeLedgerDelta` (they differ on legacy opening rows; keep both).
`event_kind` is `funding`, `scale_in`, `close`, `non_close` or `other` from stored classification.
`close_reason`/`close_extent` come only from one unique `trade_diagnostics` match on strategy, non-empty position id, symbol, close instant, quantity and exit price, else unavailable.
Entry ATR and stop/TP geometry come from the trade row only; unstamped zeros and blanks stay unavailable with the stored sentinel in `raw_value`.
Funding with no position id is `position_allocation: unallocated` and stays the strategy's.
`wallet_orphan_context` (live only, read from the primary file in its own transaction) lists Hyperliquid `funding_orphan` rows of `wallet_transfers` as `live_wallet`/`unallocated` with null strategy and position; it is outside the strategy's totals.
`cost_model_version` (version 2, issue 1726) is the paper cost model that booked the row: `available` when the stored value is at least 1, `unavailable`/`unstamped` with raw value 0 for a row booked before the stamp, and `unavailable`/`column_absent` when the file has no such column; a negative value fails the export.
Within a version, only optional fields may be added; any change to required fields, types, units, enumerations, ordering, accounting or event keys is a new version, and consumers must refuse an unknown `schema` or `schema_version`.
Version 2 differs from version 1 only by the `cost_model_version` field; `ledger_compare.py` and `fee_evidence.py` read both, and every row of a version 1 export reads as cost model version 0.

Proof: `GO_TRADER_BIN=<binary> bash scripts/test_ledger_export.sh` (Linux with a private mount namespace for the capture checks; elsewhere it checks the refusal and prints SKIP, and `LEDGER_EXPORT_REQUIRE_CAPTURE=1` turns that SKIP into a failure). The `shell-suites` CI job runs it as the runner user with `LEDGER_EXPORT_REQUIRE_CAPTURE=1` and `LEDGER_EXPORT_REQUIRE_STRACE=1` (see Tests).

### Ledger comparison (issue 1686)

`backtest/ledger_compare.py` compares one export with the `Backtester` over frozen inputs. It is research-only: it reads the export, a comparison-input file and a frozen market manifest, writes one new report file, and never touches live configuration, defaults, state, orders, the network or the cached-data loader. Acquire public market data separately (`offline_manifest.py acquire`) in an environment with no trading secrets.

```bash
uv run --no-sync python backtest/ledger_compare.py --export <export.json> \
  --comparison-input <comparison_input.json> --output <new-report.json> [--mode strict|approximate]
```

Exit 0 only for `outcome: strict_success`; 1 for any other outcome (`refused`, `unverified`, `mismatch`, `incomplete`), with the report written; 2 for malformed input (including a historical `strategy` whose `args`, `open_strategy`, `open_strategy.params` or string fields have the wrong type) or an existing output (no report); 3 for an unexpected internal error (traceback on stderr, no report). A strategy that rejects its own parameters with `ValueError` is a `strategy_rejected_inputs` refusal with the report written. `--mode` defaults to `strict`; a strict refusal is never retried as approximate. `approximate` is explicit research: it lists every approximation (the mode itself, flat costs instead of the execution spec, unverified stop inputs, assumed-inactive controls, approximate closes) and is always `incomplete`. Both modes carry the same complete stop context to the engine.

**Comparison input (`go-trader.ledger-comparison-input` version 4 is current; versions 1 to 3 stay readable; the committed live-export inputs are version 2).** Separate from the export and the market manifest; its sha256 is in the report.
`export` binds the export by schema, `capture_manifest_sha256`, selection and `booked_sections_sha256` (sha256 of the canonical JSON of `events` plus `wallet_orphan_context`), so an exporter configuration-version bump that only changes `current_effective_configuration` keeps the binding while any booked change breaks it.
`market` names the manifest (relative path and sha256), dataset and window; the window must equal `interval` (UTC, start inclusive, end exclusive).
`tolerances` gives `time_seconds` (s), `price_relative` (fraction), `quantity_absolute` (base asset) and `money_absolute` (USD).
`starting_state` gives `cash_usd`, `inventory` and `pending_decision`, each with `status`, `evidence` and a `rule`: `attested` (evidence accepted as given, except that an attested inventory is still checked against the booked replay), `booked_ledger_replay` (cash = `rule_inputs.base_value` plus the ledger delta of every booked event before the interval; inventory = booked opens minus closes before the interval) or `signal_replay` (the strategy decision on the last warm-up bar, recomputed).
`historical_configuration.timeline` holds `strategy`, `regime` and `portfolio_risk` segments with evidence; the export's `current_effective_configuration` is reported as current-at-capture evidence only and never replaces history.
`capability_evidence.portfolio_controls` may verify that drawdown, circuit-breaker and portfolio controls did not act.
Each version 2 segment declares `basis` (`raw_config`: the strategy as written; `loader_resolved`: the strategy after the loader applied defaults) and carries stop provenance.
`stop_defaults` holds `default_stop_loss_atr_mult`, `user_close_defaults`, `user_regime_atr_defaults` and `platform_max_drawdown_pct`, and `stop_evidence` holds `raw_fields` (every stop field, `leverage`, `max_drawdown_pct`, `trailing_stop_min_move_pct`), `resolved_fields` (`loader_resolved` only), `leverage_origin` (`config` or `default`) and `regime_atr_window`.
Every entry has `status` (`verified` needs a nonempty `source`) and `value`; presence entries add `present`, with verified absence as `present: false, value: null` and an explicit zero kept distinct.
A loader-resolved value never proves a field was explicit.
The optional top-level `initial_stop_geometry_evidence` maps a booked event key to `{status, source, stamp: "initial_entry"}`.
The optional version 2 segment field `atr_defaults.root_atr_method` (a presence entry) is the root `atr_method`; the loader never stamps it into the strategy.
The report records the input version it read.

**ATR method.** The comparison resolves the method as live does: the strategy `atr_method`, else a verified root value, else `simple` only when the root value is verified absent.
The resolution and its evidence requirement apply only when the simulation reads ATR (a close strategy or an ATR stop owner).
With no strategy value and no verified root evidence (always the case for a version 1 input without a strategy value), the run is refused with `atr_method_unverified`; approximate mode substitutes the declared root value or `simple` and lists it.
A strategy value or a declared root value (verified or not) other than `simple` or `wilder` is `atr_configuration_invalid` in both modes, also when the simulation reads no ATR, because live config validation rejects it at either level; it is never approximated.
The report `atr_method` section holds the method, its source (`strategy`, `root`, `live_default`, `unverified_substitute` or `not_used`) and the evidence.
Wilder ATR is recursive, so the indicator-history check needs a long warm-up when an ATR stop owner is active (1500 hourly bars passed in issue 1682's production run; 400 did not).

**Regime context (#1730).** One comparison run resolves one regime context.
`capability_evidence.directional_certification`, `regime_labels` and `regime_feature_timing` bind `partition`, `strategy_id`, `configuration_sha256` and the complete interval (`coverage: complete_interval`).
A certification entry is `reloads: none` plus exactly one of `effective: empty` or `artifact`.
Label evidence with `values_and_timing: true` is the only proof that label values and timing matched the frozen candles.
The Python certification loader is not the check: a malformed entry rejects the whole artifact, the way Go does.
A non-object `criteria`, an empty timestamp, a non-integer `schema_version`, and a state direction other than the exact strings `long` and `short` are `directional_certification_invalid`.
Flat decisions use the current expiry; an open position keeps the states frozen at entry.
A change inside the interval is `directional_certification_changes_in_window`.
Missing, hash, invalid and incomplete evidence keep those words in the reason code (`directional_certification_*`, `regime_labels_*`).
No evidence is `directional_certification_unverified` or `regime_label_availability_unverified` (approximable).
Selectors are checked before a row can be inactive: `regime_window_multi_disabled`, `regime_window_unknown`, and `regime_gate_disabled_fail_closed` (disabled regime, `allowed_regimes`, fail-closed).
A named selector with no consumer is inactive `regime_selector_no_consumer`.
A regime-owned stop consumes a named gate window, so that selector is not inactive.
Live writes one `result.Regime` payload per cycle. The position stamps, the regime-owned stop, the regime-tier label and the directional policy read one row of it, and so does the gate with `closed_bar_decisions` off.
`features.directional`, and `features.gate` with `closed_bar_decisions` off, describe that row. `unshifted_closed_candle` attests the fill bar's own close; with no token the row is the decision bar.
Tokens that disagree are `regime_feature_timing_conflict`. A token the frozen candles do not reproduce is `regime_feature_timing_unsupported` (approximable; the substitute is the decision bar).
An uncertified policy is inactive `directional_policy_uncertified`.
A bare `ranging_directional` entry is honored when a certified sub-label resolves to it, matching live `gatedDirectionalEntry`.
A gate with `closed_bar_decisions` reads one shifted closed-bar column.
With `closed_bar_decisions` off, the gate reads the attested `result.Regime` row.
Without an attested row the gate is `regime_feature_timing_unsupported`, and approximate mode uses the decision bar.
Directional checks read `result.Regime`.
A flat directional decision reads the resolved directional window, which is the primary window when the selector is default.
An open position keeps the entry stamp, the gate-window label when the directional selector is default.
Directional checks need an attested `result.Regime` row; otherwise `regime_feature_timing_unsupported`, and approximate mode uses the decision bar.
Approximate mode records that substitute and stays incomplete.
Warm-up shorter than the live lookback (at least 200) is `regime_lookback_insufficient`.
Another timeframe is `regime_timeframe_unprepared`.
`regime_window_divergence` is `regime_divergence_unmodeled`.
`regime_profile_allocation` is `regime_profile_allocation_unsupported`.
A named ATR window on the ledger path is `regime_atr_window_unsupported`. `--config` admits that window when its label column was prepared (#1732).
The engine takes `regime_label_columns`: `gate`, `directional` and `atr` columns, `payload_row` (`decision_bar`, the default, or `closed_candle`) and `gate_row` (`decision_bar` or `payload`).
An unknown key, or a named column that is absent, is refused; an absent column is not recomputed from the primary column.
Stamps survive a partial close, clear on a full close, and the same-bar reopen reads the current label.
The default constructor, with no label columns, is unchanged.

**Decision timing (#1712).** The historical strategy's `closed_bar_decisions` is a capability row (`decision_timing`) and the report `timestamps.decision_timing` section.
`true` is modeled: live decision inputs use the bar the simulator decides on, but a booked record time can still trail the bar boundary (per-strategy timer, entry gates, retries), and under `market_feed: websocket` or `shared` a decision can read a closed bar before a later venue correction.
`false` or omitted is legacy runtime timing, reported as informational with the limitation that a closed-bar simulation cannot establish forming-bar decision parity without recorded decision evidence.
A non-boolean value is refused; recognizing the flag never removes another refusal.

**Resting take-profit fills (#1727).** Comparison input version 4 (versions 1 to 3 are still read) adds an optional top-level `resting_tp_rule` `{enabled, k_ticks, source}`; in an earlier version it is an input error (exit 2).
The rule source is the paper strategy's own `resting_tp_trade_through` (a JSON boolean; absent means off) for a paper strategy, and `resting_tp_rule` for a live strategy (absent means `default_off`). `k_ticks` must equal the structural floor 1.
The capability row `resting_tp_trade_through` (`take_profit_fill_model`) records the source and is `modeled` when the rule is on, `informational` when it is off on a strategy with a tier close, and `inactive` otherwise.
It is refused, never approximated, with `resting_rule_flag_invalid` (a non-boolean flag), `resting_rule_conflict` (a paper input that disagrees with the flag), `resting_rule_live_flag` (a live strategy with the flag `true`), `resting_rule_k_unsupported`, or `resting_rule_ineligible` (no tier close, the dynamic close, scale-in, replay fields, or not Hyperliquid perps).
A rule without the execution spec refuses before simulation with `resting_rule_tick_unknown`; the rule never switches the cost mode. Turning the rule on only adds the backtester keyword after the cost block, so `cost_model`, the paper spec and `value_sources` are byte-identical with the rule on and off.
Report version 2 adds the top-level `resting_fill` section: the resolved `rule`, `simulated_tier_closes`, `booked_tier_closes`, `counts`, `failures`, `parity_scope` and `verified`.
A simulated tier close is any non-synthetic simulated close whose reason name (text before the first `:`) is one of the four rule evaluators or `tiered_tp_atr_live_regime_dynamic`; the presence of `resting_fill` never decides membership and is checked after it.
On a strategy with a tier close, a booked close is `tier` (a tier evaluator reason or `hl_sync_tp<N>_fill`), `possible_tier` (reason unavailable, as for a paper partial close, or `signal`, the reason of a paper final tier close) or non-tier. A `possible_tier` close paired within tolerance with a simulated non-tier close becomes `resolved_non_tier`. On any other strategy both populations are empty.
Failure codes: `resting_fill_unverified` (rule off and a member on either side), `resting_fill_missing`, `resting_fill_inconsistent` (not `rule: trade_through`, `k_ticks` 1, `verdict: traded_through` and `fill_px` equal to the event's raw price), `simulated_tier_close_unmatched`, `simulated_tier_close_paired_with_non_tier`, `booked_tier_close_unmatched`, `booked_tier_close_paired_with_non_tier`, `tier_close_out_of_tolerance`, and `resting_rule_suppressed_every_tier_close` (rule on, no simulated tier close, and a booked `tier` or `possible_tier` close remains).
The additive strict check `resting_tier_closes_verified` is true when both populations are empty, and otherwise only with no failure. No partition, existing strict check, tolerance or match window changes.
Parity is qualified: agreement is claimed only for tier closes on frames where no armed stop is reached, under `ohlc_walk`. An intrabar close matches a booked record time inside its bar plus the time tolerance, so a booked touch fill recorded inside the next bar's window cannot be told apart from a trade-through in that bar; the report states this limit in `parity_scope`.

**Stops.** `resolve_historical_stops` builds one stop context per segment.
`raw_config` runs `run_backtest.resolve_raw_config_stops`, the in-memory resolver the file loader also uses: normalization, the verified user close and regime-ATR defaults, then the scalar ATR default, the drawdown fallback and one percent conversion.
`loader_resolved` passes the resolved values through `live_stop_engine_inputs` once and reapplies no default.
Both bases go through `build_stop_capability_context` and central validation before the resolved owner is read, and that context reaches classification and the engine.
The required provenance is every stop field (precedence), plus the owner's own inputs (`max_drawdown_pct` and, for a raw basis, its platform default; `trailing_stop_min_move_pct`; the regime ATR window), plus `max_drawdown_pct` whenever precedence reaches the drawdown step (an owner `none` that a positive drawdown would turn into `drawdown_fallback`), plus each raw default that could apply.
A missing or unverified one is a field-named `stop_inputs_unverified` refusal (approximable).
Evidence that disagrees with the strategy or with itself is `stop_evidence_contradictory`; on a loader basis this includes a resolved `max_drawdown_pct` outside (0, 100] and a resolved `stop_loss_atr_mult` the live scalar default could not produce (a raw scalar stop field, an active regime block or a unified close skips it).
A raw basis that resolves `max_drawdown_pct` outside (0, 100] is `stop_configuration_invalid`, because live config validation rejects it.
On a loader basis a `config` leverage is verified only with a verified `resolved_fields.leverage`.
The central `UNSUPPORTED_STOP_OWNER`, `MISSING_STOP_INPUT` (including an unsupported named window) and `UNVERIFIED_MARGIN_LEVERAGE` (a defaulted or unverified leverage) are `stop_capability_refused` with `reason_code`.
These refusals are never approximated.
Version 1 has no basis or provenance, so it cannot rule out an explicit stop, an implicit ATR default, a close-owned stop or the drawdown fallback: a Hyperliquid perps segment is refused with `stop_inputs_unverified` (`basis` plus the owner's inputs).
A regime-owned stop gets labels from the historical regime configuration (`regime.enabled` is its label input; a disabled regime is `MISSING_STOP_INPUT`).
When the ATR selector is default, protection uses the gate-window stamp: the named gate window, or the primary column when the gate selector is also default.
The stamp is on the `result.Regime` row (Regime context above): the unshifted closed candle when verified timing evidence attests it, with or without a gate or policy, otherwise the decision bar.
A named ATR window stays refused for issue 1732.
The `max_drawdown_pct` portfolio-control row stays separate from its stop-fallback row.
The report `stops` section holds the basis, owner, required inputs, live and engine values, the capability context and every simulated arm.

**Initial stop geometry.** After matching, each simulated first arm is mapped to its position by event sequence, bar time and side. The booked initial-entry row's `stop_loss_trigger_px` is compared with that arm within `price_relative`, with booked and simulated `entry_atr` beside it, only when `initial_stop_geometry_evidence` verifies the row as the initial-entry stamp. A close or scale-in stamp, or a position whose first in-interval row is not its opening, never establishes the first arm. No recorded trigger is `unavailable`, which is never agreement and never blocks. A stamp without verified provenance, an attestation of another row, or an unmapped arm is `unverified`. A verified disagreement is `mismatch` and fails the pair's tolerance. Both block strict success through `initial_stop_geometry_consistent`.

**Strict eligibility.** A missing or unverified input makes the result `unverified` (diagnostic only); a recomputed rule that disagrees with the declared value is unverified.
Starting inventory under either rule is unverified when the booked replay disagrees with it or cannot be computed (a booked event before the interval with unresolved identity).
Refusals (`refused`): input binding mismatch, a configuration change inside the interval, a non-flat start (seeded inventory, declared or shown by the booked replay), a manual owner (every event `not_comparable`), a non-perps or non-Hyperliquid owner, a missing close strategy (the execution-spec path needs the open/close engine), scale-in, stop refusals (above), leverage or sizing leverage above 1, `capital_pct` (`sizing_capital_pct`) and the #1728 sizing refusals, regime rows that are not modeled (the regime context above), Hurst gate, HTF filter or HTF strategies, hedge, replay, funding-input strategies, unknown active fields, portfolio controls without verified non-intervention evidence, a pending entry on the last warm-up bar, and frozen-input gaps.
Frozen checks: manifest and file hashes; one exact candle grid across warm-up and scoring (no gap at either edge or the join); finite, consistent OHLCV; hourly funding for every hour the simulator attaches to a scored bar (after the previous bar open through the last scored bar open); scored decisions, and the ATR and regime labels a stop owner reads, unchanged when a third of warm-up is removed; for observation-input strategies, the strategy's own per-bar observation validity on every scored bar.
The central close contract is decoded with `decode_close_validation`; strict success needs `mode: strict`, `close_eligibility: eligible` and no approximation, and a passing close validation alone never makes strict success.

**Simulator events.** `Backtester.run(record_events=True)` adds `metrics["ledger_events"]` (`go-trader.backtester-ledger-events` version 3), written where the engine does the arithmetic: unrounded open, scale-in, partial and full close, funding accrual, seeded inventory and the synthetic terminal liquidation, each with timing meaning, decision and bar times, quantity, raw and effective price, fee charged, entry-fee allocation and outstanding entry fee, gross realized, funding, and quantity, average cost and cash before and after. `interval_end` records inventory and cash before the synthetic end-of-data liquidation; the comparison uses that boundary. With the option off nothing changes; with it on every other result field is unchanged.
Every event has `resting_fill`, null except on a resting tier booking: with the rule on it is the evidence of the close evaluation that set the winning fraction (a non-winning evaluation's evidence is never attached), and with the rule off under the resting model it is `{rule: legacy_mark_cross, k_ticks: null, verdict: resting_fill_unverified, reach_px, fill_px}`.

**Normalization and matching.** Booked events become position outcomes by recorded `position_id`; a missing id, a non-fill event kind, another symbol or a missing quantity/price is `unresolved_identity`, and contradictory sides stay unresolved.
Events before or after the interval are `outside_interval` and stay listed; a position open at the interval end is compared on its in-interval components and its booked residual, and the booked end inventory also counts a position held through the whole interval with no fill inside it.
Strategy funding stays unallocated and is compared only as a strategy total; wallet-orphan context is reported apart and never enters strategy totals.
Paper funding rows (Paper Funding Accrual below) carry the captured `position_id`; they still count only as strategy funding, and the informational `rows_with_position_id` count reports them.
A held position does not guarantee a strict funding mismatch: zero funding, offsetting payments or a delta within tolerance can pass, and equal paper and simulated funding cash needs equal exposure timing, rates and valuation prices.
`row_net_pnl` and `ledger_delta` are taken from the export as authoritative (legacy opening rows differ) and fees are never subtracted again.
A booked and a simulated position are candidates only with the same side and an opening fill inside the time window (bar open plus or minus the tolerance; intrabar fills add the bar length), price and quantity tolerances; nearest time alone is never enough.
Assignment is global and one-to-one: a component with more than one maximum matching is `ambiguous` with every candidate listed, and leftovers on either side are `unmatched`.
Every booked and simulated event gets exactly one disposition.
Every conservation check can fail: each booked row's `ledger_delta` and `row_net_pnl` recomputed from `realized_pnl`, `exchange_fee`, `is_close` and `pnl_gross`; every booked event listed in exactly one report section that agrees with its disposition, and booked fees and ledger deltas summed over those sections equal to the ledger totals; each matched pair's compared and leftover booked components equal to the position's in-interval fees and ledger delta; no position closing more than it has opened in record-time order; wallet-orphan exclusion; and the simulator's cash continuity, cash identity, entry-fee allocation and quantity.
Unallocated strategy funding is reported under `informational_totals`, outside `conservation_passed`.
Recorded fees and funding are reported apart from modeled ones; a cause is set only when evidence supports it.

**Fixture.** `backtest/testdata/ledger_export/` holds the real capture (`snapshot/`: only `capture.json`, `config.json`, `state/primary.db`), the real exports (`export.json` strict-supported, `export_scale_in.json`, `export_manual.json`), their comparison inputs, the frozen synthetic market (`market/`), `fixture_production.json` (documented captures and the export list) and the production sources and instructions (`source/`, `README.md`).
`scheduler/ledger_export_fixture_test.go` (`TestLedgerExportFixture`, Go CI, no Python) runs `runLedgerExport` over the snapshot into a temporary file and requires byte equality with every listed export; `backtest/tests/test_ledger_compare.py` and `test_backtester_ledger_events.py` (Python CI, no Go) read the committed files.
Regenerate exports only on purpose (export schema change, configuration-version increase, changed effective defaults or serialization): `GO_TRADER_REGENERATE_LEDGER_EXPORT_FIXTURE=1 go -C scheduler test -run '^TestLedgerExportFixture$' -count=1 .`.
It refuses unless the snapshot and `source/seed.sql` match the latest documented capture, and, without an export schema-version change, it may change only `current_effective_configuration`, plus `capture_manifest_sha256`, `capture` and `snapshot_files` after a documented re-capture, plus `events` and `wallet_orphan_context` only when that capture records a changed seed.
When `MinSupportedConfigVersion` passes the snapshot's configuration version, re-capture on Linux per the fixture README.
`export_paper_resting.json` and `export_paper_resting_touch.json`, with their version 4 inputs over `resting_market/` (the `market/` candles with two highs raised), are synthetic paper fixtures with a `tiered_tp_atr` ladder and `resting_tp_trade_through: true`, made by `source/build_paper_resting_fixture.py`. The first reaches strict success with one trade-through tier close; the touch variant books that close in the touch bar's window and fails only `matched_within_tolerance` and `resting_tier_closes_verified`.

**Cost model by partition (issue 1726).** A `live` export keeps the manifest costs, and the report `cost_model` adds `value_sources` naming the manifest keys.
Without an execution spec (scale-in strategies), the live simulation also passes the manifest `maker_fee_pct` as the backtester `maker_fee_pct`, so both live paths price resting tier fills from the same manifest value; a paper export without a spec passes its frozen `tier_fee_pct`.
Both live kinds add an informational `tier_fee_check` (`maker_fee_pct`, `taker_fee_pct`, `maker_equals_taker`, `rule`). The live comparison charges the manifest `maker_fee_pct` on simulated tier fills, while paper and the backtester without a maker rate charge taker (see Paper fill cost model), so a Hyperliquid comparison manifest must set `maker_fee_pct` equal to `taker_fee_pct` to match the evidence. The committed manifests in `backtest/testdata/ledger_export/` are frozen at a maker rate of 0.00015 and do not meet this rule yet. `maker_equals_taker: false` means the simulated tier fees differ from what the evidence supports. The check adds no refusal and no approximation and never changes the outcome.
A `paper` or `paper:<id>` export is simulated with the paper fill cost model of the version stamped on its rows: the frozen `PAPER_FILL_COST_MODELS[<version>]` taker and tier rates, the paper slippage and zero half spread, whatever the manifest cost block says.
With the execution spec, only the cost fields are overridden; `size_decimals`, `min_notional_usd` and `min_notional_margin` stay from the manifest.
The version is the one value shared by every in-interval fill row (funding excluded); with no in-interval fill it is the current `FILL_COST_MODEL_VERSION` (`version_source: no_in_interval_fills`).
The report `cost_model` is `{kind: "paper_fill_cost_model", version, version_source, values: {name: {value, source}}}` plus the spec or flat values used.
Refusals: `paper_cost_model_partition_unknown` (a partition that is neither live nor paper), `paper_cost_model_mode_disagrees` (a paper partition with live args, or a live partition without them; live args follow `isLiveArgs`), `paper_cost_model_mixed` (rows from more than one version), `paper_cost_model_unknown` (no history entry), `paper_cost_model_replay_unsupported` (rows stamped `1001` by an offline basket replay cost policy, #1791; their policy evidence stays in the replay state file and default paper costs are never substituted), all in both modes, and `paper_cost_model_not_reproducible` in strict mode for version 0 (random slippage); approximate mode runs version 0 with zero slippage and lists the approximation.
The check runs only for a non-manual owner with a resolved configuration segment.
The price tolerance stays an input, because decision timing (issues 1712 and 1723) still moves the reference price.
`export_paper.json` with `comparison_input_paper.json` is a synthetic paper fixture made by `source/build_paper_fixture.py` from the live fixture; it reaches strict success at a relative price tolerance of `1e-9`.

**Limitations.** Strict support covers flat-start, single-configuration Hyperliquid perps owners on the open/close engine with verified stops and no scale-in, leverage, regime gating or HTF inputs. The committed exports carry no stop stamps, so their initial geometry is `unavailable`; agreement is proven only on synthetic test copies. Booked timestamps are ledger record times, not exchange fill times. Live sizing is not modeled beyond the simulator's all-cash sizing from the verified starting cash; quantity deltas expose differences. Separate state files are separate snapshots; the comparison makes no cross-file simultaneity claim.

### Fee evidence (issue 1726)

`backtest/fee_evidence.py` measures the Hyperliquid fee rates and slippage of booked live fills from one or more exports. It is offline and suggest-only, makes no network call and writes no config. See the `docs/backtesting-registry.md` row for inputs and output.
Every export must have `selection.platform = hyperliquid`; any other platform exits 1. Rows count only when the partition is `live`, `fee_source` is `userfills` and `value` is nonzero; funding, modeled and `reconcile_adjustment` rows are counted as excluded. Hedge-leg rows are `hedge` and operator rows (`manual = true`, including manual and manual-limit opens and adds) are `manual`; other opens and adds are `open` and `add`, and close rows are grouped by `close_reason`, else by the booking code's details prefix; anything else is `unclassified` and listed. A row that appears in several exports (same storage strategy, source role and row id) counts once, and the run refuses when the copies disagree.
Live stop slippage needs venue fills: the reconcile path books a confirmed live stop at the stored trigger (`hyperliquid_balance.go`), so its exported `price` equals `stop_loss_trigger_px`. Pass raw `userFillsByTime` captures with `--user-fills` (account address only, no trading key); fills join rows by order id, and the role comes from the fill's `crossed` flag. A trigger-priced row with no venue fill is `booked_at_trigger_no_venue_evidence`, never a zero-slippage sample. Market-fill slippage needs a `--fill-log` extract and is labelled "reference price as logged"; it uses the exact booked fill price, so only the 2-decimal logged reference is rounded, and samples whose rounding bound exceeds 0.5 bps are excluded.
Production evidence is captured read-only (export capture and `export ledger`, no `--once`, no restart, no state write). Post only the derived numbers; never post or commit the account address, raw fills or exports.

### Paper fill cost model (issue 1726)

The Hyperliquid taker rate 0.045%, maker rate 0.015% and paper slippage 0.05% in `scheduler/fees.go` are code constants.
Fee evidence (2026-10-08, [issue 1726 comment](https://github.com/richkuo/go-trader/issues/1726#issuecomment-6055361624)): an operator ran `fee_evidence.py` on a read-only capture of 4 live Hyperliquid exports completed 2026-10-06. The exports, logs and report are private, so the repository cannot verify this result.
It reports that every booked `userfills` group's median and maximum is at or below `HyperliquidTakerFeePct`, so the taker constant is a conservative upper bound; the exact rate is not published.
The maker rate is not measured: the capture has 0 booked take-profit tier rows and no venue fills with a maker or taker role, so tier fills cannot be classified as maker or taker. Live stop slippage is unavailable (no venue fill capture).
Decision: both rate constants stay, paper and the non-spec backtester without a maker rate charge taker on resting take-profit tier fills, and `FillCostModelVersion` stays 1.
The one exception is the live ledger comparison, which charges the manifest `maker_fee_pct` on simulated tier fills and reports it in `tier_fee_check`.
To match the evidence, a Hyperliquid comparison manifest must set `maker_fee_pct` equal to `taker_fee_pct`. The committed manifests in `backtest/testdata/ledger_export/` are frozen at a maker rate of 0.00015, so they do not meet this rule yet, and a live strict comparison on them charges 0.00015 on tier fills and reports `maker_equals_taker: false`.
The maker constant 0.015% is an unverified assumption that no tier fill uses by default.
`ApplyAdverseSlippage(price, isBuy)` returns exactly `price * (1 + SlippagePct)` for a buy and `price * (1 - SlippagePct)` for a sell, the backtester formula; there is no random slippage.
Paper market opens, flips, signal closes and partial closes on perps, spot and futures take it inside the existing no-fill branch (`fillQty == 0` or `fillContracts == 0`), so a venue-filled live row keeps its venue price.
OKX and TopStep live executes whose result has no fill (or `AvgPx <= 0`) also reach that branch, so those live modeled rows now book the deterministic adverse price instead of a random one; Hyperliquid and Robinhood live never reach it: Hyperliquid opens only after `confirmHyperliquidExecuteFill` confirms the fill, and Robinhood books only a settled confirmed fill from `robinhood_order_attempts` (`confirmRobinhoodFill`, #1815).
Paper Hyperliquid scale-in adds book at the mid moved against the add side; the add quantity stays the decision quantity.
A paper stop books the worse of the trigger and the mark moved against the position (`paperStopBookPx`), applied only at the six paper stop sites (`applyPaperStopLossBreach`, `advancePaperDynamicCloseRegime`, the open-cycle arm, the main-loop trailing and fixed stops, the ratchet same-cycle trailing stop); the shared booking functions are unchanged, so live stop bookings keep their trigger or venue price, and the close row keeps the unslipped `stop_loss_trigger_px`.
A paper take-profit tier fill books at the tier price with no slippage and the taker fee.
The replay mirror books an open at the live reference price moved against the trade, with the live quantity; that reference is the live venue average, so a replay open or add carries the real live slippage plus the paper slippage.
A replay add books the same way; a replay take-profit close (`replayCloseReasonIsTakeProfit`) books at the row's live tier price with no slippage, else at the cycle price with no slippage and `tier_price_source=cycle_price` in the details; any other replay close books at the cycle price moved against the close side.
Exceptions in version 1: paper hedge legs and the paper hedge unwind book at the mark with no slippage (the backtester rejects hedge).
`CalculateHyperliquidFee` and `CalculatePlatformSpotFee` stay the taker rate because they are the live fallback fee, and `executionFee` uses the venue fee only when it is greater than 0, so a zero-fee or rebate venue fill books the modeled taker fee with `fee_source = modeled`.
Every new trade row stores `cost_model_version` = `FillCostModelVersion` (1), set only by `RecordTrade`; inside the offline basket replay adapter alone (#1791) a non-default replay cost policy is bound for the whole process, and `RecordTrade` stamps `FillCostModelReplayCustomVersion` (1001) with the run's `cost_policy_id` instead (the frozen policy evidence is the `fill_cost_policies` row), while a policy equal to the default paper costs stays version 1; rows booked before this change keep 0 and are never restamped, because loads read the stored value and saves skip persisted rows.
A research pair member is the exception (#1887, Research pairs, Member cost bindings): its execution and native-close rows carry model 1001 and the member's `pair-cost-` policy id from its admission, never the process policy, and its fee and slippage come from that binding on both venues (an OKX member does not use `OKXPerpsTakerFeePct`); its funding rows keep the process stamp.
Version 0 means random slippage on market fills, no stop slippage and taker on every fill; version 1 is the model above.
Any change to `SlippagePct`, a Hyperliquid fee constant or which fill kind pays maker or taker raises `FillCostModelVersion` and adds a new literal `PAPER_FILL_COST_MODELS` entry in `backtester.py`; existing entries are never edited, and `test_platform_fees.py` holds a literal copy of each.
Live rows carry the stamp too; only paper comparisons use it.
Every paper strategy's booked fill prices change at the deploy of this model, so a paper comparison window (issue 1723) must not span that deploy.

---

## `/go-trader` Command

When the user says `/go-trader`, "check bot status", "show strategy health", or "how are the bots doing":

```bash
curl -s localhost:8099/status | python3 -c "
import json, sys
d = json.load(sys.stdin)
strats = d.get('strategies', {})
print(f'=== GO-TRADER (Cycle {d[\"cycle_count\"]}) ===')
for sym, p in sorted(d.get('prices', {}).items()):
    print(f'  {sym}: \${p:,.2f}')
val = sum(s['portfolio_value'] for s in strats.values())
cap = sum(s['initial_capital'] for s in strats.values())
pct = ((val-cap)/cap)*100 if cap else 0
print(f'\nPortfolio: \${cap:,.0f} -> \${val:,.0f} ({val-cap:+,.0f} / {pct:+.1f}%)')
cb = [(i,s) for i,s in strats.items() if s['risk_state'].get('circuit_breaker_until','').startswith('20')]
print(f'Strategies: {len(strats)} | Circuit breakers active: {len(cb)}')
ranked = sorted(strats.items(), key=lambda x: x[1]['pnl_pct'], reverse=True)
for label, rows in (('Top 5', ranked[:5]), ('Bottom 5', ranked[-5:])):
    print(f'\n{label}:')
    for i, s in rows:
        print(f'  {i}: {s[\"pnl_pct\"]:+.1f}% (\${s[\"pnl\"]:+,.0f}) | {s[\"trade_count\"]} trades')
dead = [i for i,s in strats.items() if s['trade_count'] == 0]
if dead:
    print(f'\nDead (0 trades): {len(dead)} - {dead}')
for i, s in cb:
    rs = s['risk_state']
    print(f'  CB {i}: dd={rs[\"current_drawdown_pct\"]:.1f}% / max={rs[\"max_drawdown_pct\"]:.0f}% | until {rs[\"circuit_breaker_until\"][:19]}')
"
```

Present the output as readable prose. Highlight circuit breakers, dead strategies, large PnL changes, and missing status data. When `/status` reports `drawdown_reading_substituted`, say so — the drawdown figure is carried forward, not measured this cycle.

---

## `/menu` Command

When the user says `/menu`, "show menu", "what can I configure", or "help me get started", present these five groups and pull the live detail from the named source rather than from memory:

1. **Trading platforms** — Strategy Reference § Platform conventions.
2. **Available strategies** — list them from the registries, never from memory (Strategy Reference), plus `/go-trader-closing-strategies` for close evaluators.
3. **Adjustable settings** — Adjustable Settings.
4. **Commands** — the operator CLI below.
5. **Backtesting** — Backtesting.

Operator CLI:

```bash
./go-trader init [--json '{…}' --output scheduler/config.json]
./go-trader manual-open <strategy-id> [--side long|short] [--size N | --notional N | --margin N]
./go-trader manual-open <strategy-id> --limit-price N [--tif Alo|Gtc] [--expire-after N]
./go-trader manual-add <strategy-id> [--size N | --notional N | --margin N]
./go-trader manual-cancel <limit-order-id>
./go-trader manual-close <strategy-id> [--qty N]
./go-trader force-close <strategy-id> [--qty N] [--dry-run]      # live HL perps strategy close
./go-trader manual-update-sl <strategy-id> --trigger N [--symbol Y] [--dry-run]
./go-trader manual-cancel-sl <strategy-id> [--symbol Y] [--dry-run]
./go-trader backfill hl-fees [--strategy <id>|--all] [--apply] [--reset-cash]
./go-trader backfill trade-ledger [--strategy <id>|--all] [--apply] [--reset-cash]
./go-trader diagnostics [--strategy <id>]
./go-trader inspect <strategy-id> [--all] [--json]
./go-trader export tradingview [--strategy <id>|--all] --output <file>
./go-trader export capture --config <source-config> --output-dir <new-dir>      # Linux; read-only snapshot set
./go-trader export ledger --manifest <dir>/capture.json --partition <live|paper|paper:<id>> --strategy <id> --output <new-file>
./go-trader agent-info [--bootstrap-md] [--append-changelog]
sudo systemctl start|stop|restart|status go-trader
journalctl --namespace=+go-trader -u go-trader -n 50 --no-pager
curl -s localhost:8099/status | python3 -m json.tool
```

`agent-info --bootstrap-md` writes `AGENTS.generated.md`, never `AGENTS.md`.

---

## Manual Trading (HL perps)

`type: "manual"` on Hyperliquid gives hand-driven entries and exits scheduler-tracked P/L, close evaluators, and Discord trade DMs. Skeleton — no `script`, `args`, or `interval_seconds`, the loader fills them:

```json
{"id":"hl-manual-btc","type":"manual","platform":"hyperliquid","symbol":"BTC","capital":1000,"leverage":3,"max_drawdown_pct":10}
```

**Close defaults:** with `regime.enabled` and a resolvable per-regime trail, a manual strategy defaults to `trailing_tp_ratchet_regime` (the regime trail owns the stop). Otherwise `tiered_tp_atr_live` (TP1 at 2× ATR, TP2 at 3×) with a scalar stop at 2.0× ATR. The tiers rest frozen on-chain, and the evaluator prices them from the same entry ATR, risk anchor and position regime. Override through `close_strategy`, an explicit stop field, or `user_defaults.manual`.

Manual and perps strategies may share a coin. Owner guards prevent cross-strategy mutation, a full close never flattens a peer's position, and all take-profit order IDs are cancelled on full close. Peers must share `leverage` and `margin_mode`, and at most one may own a trailing stop.

```bash
# Open — at most one of --size, --notional, --margin
./go-trader manual-open hl-manual-btc                              # defaults: --side long --margin 50
./go-trader manual-open hl-manual-btc --side long --size 0.01
./go-trader manual-open hl-manual-btc --side short --margin 100    # margin × leverage = notional
./go-trader manual-open hl-manual-btc --side long --size 0.01 --atr 850   # skip the ATR fetch

# Scale in — side inferred, blends avg cost, freezes the risk plan
./go-trader manual-add hl-manual-btc --margin 50

# Edit the resting stop in place
./go-trader manual-update-sl hl-manual-btc --trigger 66000
./go-trader manual-cancel-sl hl-manual-btc

# Close — full or partial
./go-trader manual-close hl-manual-btc [--qty 0.005]

# Record-only (order placed on the HL UI; the scheduler tracks it)
./go-trader manual-open  hl-manual-btc --side long --size 0.01 --record-only --fill-price 67800
./go-trader manual-close hl-manual-btc --qty 0.005 --record-only --fill-price 68250
```

Guardrails:

- `--record-only` skips the live order; pair it with `--fill-price`. The stop is **not** auto-armed — place the trigger on the UI yourself.
- Stop and take-profit reduce-only orders are placed inline on open. Omitting `--atr` auto-fetches ATR(14) for the strategy's symbol and timeframe, defaulting the fetch to 1h when the strategy has no `timeframe`. A failed fetch falls back to `0.1 × fillPrice / leverage` with one combined notification. The success log names the timeframe used.
- `--side` defaults to `long`. With no sizing flag and no `--record-only`, `--margin 50` is applied, so a bare `manual-open <strategy-id>` works as a smoke test.
- The manual default stop is 2.0× ATR, distinct from the fleet-wide `default_stop_loss_atr_mult` (typically 1.0×) for non-manual HL perps. An explicit stop field still wins. `user_defaults.manual.stop_loss_atr_mult: 0` opts manual out without touching non-manual perps; the ratchet fallback ignores that 0.
- Opening is blocked while the portfolio kill switch is active or the strategy has a pending circuit-breaker close.
- Live market `manual-open`, `manual-close`, and `manual-add` refuse without queueing when the exchange returns no confirmed fill (`AvgPx>0` and `TotalSz>0` required); state is unchanged. The dashboard trade-actions API returns HTTP 409 with the same error.
- A full `manual-close` that fills short of the book (a capped plan or a partial IOC fill) books the fill, queues the close, and in the same command restores the stop it asked the venue to cancel through the verify-first stop-loss restore below (`restoreManualStopLoss`), at the recorded trigger.
  The stop size comes from `resolveHLCloseRemainderStop` with the same rule and reading order as the cycle re-arm (post-close read first, then the pre-send reading less the fill, then the remainder).
  No backed units places no stop and sends a CRITICAL alert; a stop smaller than the remainder, or one sized without the post-close read, also sends one CRITICAL alert that names the unbacked units or the basis.
  With no backed units, the same command also cancels verify-first the pre-close stop when its cancel was not confirmed (`--update-stop-loss --size=0`, booked as a `cancel-sl` row) and every take-profit whose cancel was not confirmed, and the one CRITICAL alert names each id as removed, already filled, still resting or unverified.
  With backed units, a take-profit whose cancel was not confirmed is cancelled verify-first in the same command (`removeManualTakeProfitsAfterShortFill`) and cleared from the book through a `restore-tp` row, with a CRITICAL alert only when one could not be removed.
  The cancelled and removed tiers return at the scheduler's protection sync, sized to the book, after the queued close applies.
- A rejected full `manual-close` restores the exchange-side protection it asked the venue to cancel, before it returns the error.
  The stop-loss leg re-arms first (verified on-chain, never assumed live), sized by `resolveHLCloseRemainderStop` like the cycle re-arm, and reports whether it left the position flat (no own-side units back the book, which after an unknown outcome most likely means the close filled after the lost reply, with a CRITICAL alert);
  with no own-side units behind the book it also cancels verify-first the pre-close stop and the tiers whose cancel was not confirmed, and names each id in that alert;
  a close the script never sent (`order_outcome: not_sent`) that confirmed no cancel restores nothing on either leg, reads no account and sends no restore alert (a missing `order_outcome` stays unknown and restores);
  the take-profit leg then rebuilds the cancelled tiers through the shared protection planner (`buildHyperliquidProtectionPlan`) and the shared reduce-only placement path (`check_hyperliquid.py --sync-protection`) with the stop-loss multiplier and force-replace zeroed, so the restored stop is never touched and there is no second placement implementation.
  It runs only for live Hyperliquid strategies that place on-chain tiers (`hyperliquidPlacesOnChainTPs`), and places nothing when the stop-loss leg left the position flat, when a fresh account read reports the position gone or reversed, or when a fresh book read shows a different trade position, side, owner or zero quantity.
  Per tier: a confirmed-cancelled tier is re-placed; a tier whose cancel was never confirmed is verified on-chain first (still resting = preserved, already filled = left to the confirmed-fill reconciler, unreadable open orders = reported UNVERIFIED and left alone, never blindly re-placed); a cleared armed tier stays completed; a tier the close never asked to cancel is left to the scheduler.
  Restored take-profit sizes use `hlOwnStopShare` (`Q` at or under the book and the own-side chain), so shared-coin peers keep their own orders, and the tier geometry comes from `riskAnchorPrice()` and the position's regime label, matching the scheduler.
  The command line saves the book and queues a `restore-tp` action carrying the previous and restored order ids, the armed flags and the trade position id (`prev_tp_oids_json`, `tp_armed_tiers_json`, `position_id` on `pending_manual_actions`); the dashboard applies the same change to scheduler memory under its state lock and queues nothing.
  Adoption is per tier and idempotent across restarts: it takes the restored id when memory still holds the previous id or a cleared, unarmed tier, is a no-op when memory already holds the restored id, and otherwise keeps memory and raises a CRITICAL naming both ids.
  An action whose trade position id no longer matches the book is acknowledged without mutating anything, so a stale row never attaches orders to a replacement position.
  Both legs run under the per-symbol protection lock (`lockHyperliquidProtectionSync`, taken before `mu`), which the in-process `runHyperliquidProtectionSync` also takes; the cycle sync additionally takes the strategy file's manual-action lock without waiting and holds it through placement and bookkeeping (a manual command holds it across its whole run and queues its action row in the same transaction that saves the book) and skips the symbol for the cycle while a position-changing action (`open`/`add`/`close`/`update-sl`/`cancel-sl`/`restore-tp`) is queued, so the cycle does not place protection during an operator command or before it adopts the queued recovery.
  The skip has three bounds, because an unbounded one would leave a live position with no maintained protection.
  First, the owner decides: `runHyperliquidProtectionSync` takes an `hlProtectionGuardMode`, and the failed-close owner (`rearmProtectionForCloseRemainder`, reached from the cycle only after a submitted order requested the cancel) passes `hlProtectionGuardStopLegAfterFailedClose`, which re-arms the stop-loss leg with `stopLegOnlyProtectionPlan` stripping every tier input, so the queued row still owns the take-profit ids while the stop goes back in the same cycle; a strategy whose stop belongs to another owner returns without placing, since the trailing and scalar arms cover it.
  Every other owner passes `hlProtectionGuardFull` and skips whole.
  Second, three consecutive blocked syncs for one strategy and symbol raise the CRITICAL notification once (`notifyHLProtectionGuardStall`), and a sync that runs clears the count, so an operator command's ordinary one-cycle hand-off stays quiet while a stuck row is reported.
  Third, `drainPendingManualActions` gives every retained row a terminal state: a `restore-tp` row whose position is gone, whose trade position id no longer matches, or whose position is now owned by a peer is acknowledged without mutating the book (the ownership case raises a CRITICAL naming the restored ids, because adopting them would attach the orders to another strategy's position), and any action whose apply keeps failing for longer than `staleManualActionMaxAge` (one hour, measured from `created_at`) is acknowledged with a CRITICAL that names the action and the error.
  A row with no readable insert time never expires.

The command line writes the book change and the `restore-tp` row in **one** transaction (`StateStore.SaveStrategyBookQueueingManualAction`, which threads the insert into `saveStrategyBookWithAcks`), and the stop-loss re-arm's `update-sl` / `cancel-sl` row goes the same way. A restored order id is therefore never resting on the venue with neither the book nor a queued row naming it: a rejected write rolls both back, and the alert path reports the placed ids.

**Unresolved take-profit placements.** A `place_take_profit_limit` call whose reply is unreadable, or which raises after the venue accepted the order, is resolved the way the stop-loss path already resolves its own: `_place_tp` snapshots the open-order ids before the call and, on an unreadable status or an exception, re-reads them through `_resolve_placement_by_book_diff` (the generalised `_resolve_sl_placement_by_book_diff`).
Exactly one fresh id takes the tier; no fresh id keeps the error and leaves the tier unplaced; anything else (a failed snapshot, a failed re-read, two or more fresh ids) sets `tp_outcome_unknown[idx]`.
An unknown tier is recorded with id 0 and **armed**, on both the cycle path (`applyUnknownTPPlacementOutcome`) and the recovery path (`manualCloseTPOutcomeUnknown`), so the next cycle places no second order at the same price, and both paths raise the CRITICAL that names the tier and asks for exchange verification.
An explicit venue rejection is still a plain error, never a book diff.
Missing or unverified protection raises the same CRITICAL as the stop-loss re-arm, naming the symbol and the affected order ids and asking for exchange verification and restoration or closure; no message promises the next scheduler cycle, the close still returns its original error, and no fill price, fee or closed quantity is ever invented.
- Fills queue in `pending_manual_actions` and apply at the top of the next cycle; the running daemon drains them itself.
  `--once` takes file ownership, so it refuses while the daemon is up — start the daemon rather than racing it.
  Each drained action is acknowledged **by row id inside the transaction that persists its effect**, so an action that fails to apply keeps its row and is never deleted by a later success, in its own file or the other one.
  If the queue insert fails after a successful on-chain fill, the cleanup closes that fill through `planHLCloseOrder` (the strategy's own side, the unqueued fill counted once on top of the strategy's own book, the peers' books split by side, a fresh account read) as a lot-floored sized close through `close_hyperliquid_position.py --close-mode`, and cancels the new stop and take-profits only after a confirmed fill covers the whole fill.
  It reports success only then.
  When those books cannot be read, the same close is sent only when no other live Hyperliquid strategy uses the coin as its symbol or its hedge coin: the fill is planned alone, with zero peers, against the fresh account read, so an unread same-side book is left open and an unread opposite-side book caps or skips the order.
  A configured peer on the coin, a skipped plan (a flat shared net does not prove the fill is closed), a deferred plan, a rejected, unknown, partial or capped close, or an unconfirmed trigger cancel alerts loudly with the open quantity and the protection ids — flatten by hand.
- A 99% partial close is never silently collapsed into a full close: the queue carries the explicit `--qty` intent.
- `manual-update-sl` / `manual-cancel-sl` cancel-then-place (or cancel) the on-chain stop, then queue an action the daemon drains into memory — no direct state-DB write, no restart. They are **hard-rejected** when the strategy's automated protection would re-pin the edit next cycle; only strategies opted out of auto-stops qualify, and the error names the opt-out. `update-sl` also refuses a trigger that would fill immediately against the current mark. A stop edit records no trade.
- `force-close <strategy-id>` closes a position on a **live Hyperliquid `type=perps`** strategy — the automated-strategy analog of `manual-close`.
  It rejects paper mode and every non-HL or non-perps strategy.
  A sole-owner or escalated full close sends the whole-position close (`market_close(sz=None)`) and cancels the on-chain triggers only after a confirmed fill.
  Every other close (a `--qty` partial, or a shared-coin full close that does not escalate) is planned by `planHLCloseOrder` like `manual-close` (the strategy's side, the peers' books, a fresh account read; a failed read sends only an uncapped reduce-only close) and sent through `close_hyperliquid_position.py --side --close-mode=reduce_only|cross`, which floors the size with `floor_lot_size` and never rounds up.
  A skipped or deferred plan, a size that floors to zero, or an unreadable mid price sends no order and cancels no protection; `--dry-run` prints `sized reduce-only N` or `sized cross N`.
  A full close passes its protection ids with `--cancel-protection-after-close --cancel-min-fill=<book - hlFullCloseTolerance(book)>`, where `hlFullCloseTolerance` is the smaller of 0.0001 and 1% of the book, so the threshold stays positive for a book of 0.0001 or less.
  The script cancels them only after a confirmed fill covers the whole book; a capped plan passes none.
  The manual-open cleanup and the fail-closed hedge unwind use the same threshold.
  Only a confirmed finite fill is booked, never the requested size; a rejected or unknown outcome books nothing, sends no second order and leaves protection in place (unknown sends a CRITICAL alert).
  A full close that fills short of the book, or is capped, books the fill and resizes the remainder's stop through the verify-first `restoreManualStopLoss` (sized by `resolveHLCloseRemainderStop`) and removes the unconfirmed take-profits for the next sync, with the manual-close alerts.
  The coupled hedge leg close plans with the leg's own side and book and no peers (the hedge coin is exclusive) against a fresh read, and books only its confirmed fill.
  Outside this lane: the kill-switch whole-coin close passes no size, and the regime orphan close and the circuit-breaker pending close (both refuse shared coins and cap from an existing snapshot) still send an explicit size through the legacy `market_close(sz)` path, which rounds with `round()`.
  The booked leg records a `force_close` trade and, unlike a manual close, updates the strategy's risk state, so the circuit breaker sees it.
  A full close is refused while a stop edit is queued.
  `--qty` closes a partial.
  `--dry-run` sends no order and writes no state.
  A sole-owner whole-position preview makes no account read.
  An escalated whole-position preview makes the shared-coin floor's one read-only account read and sends no order.
  A sized preview (`--qty`, or a shared-coin full close that does not escalate) makes one read-only account request, prints `sized reduce-only N` or `sized cross N`, and still sends no order.
- A full `manual-close` or `force-close` on a **shared coin** (two or more live strategies on it) whose closing value is under the venue minimum gate takes one deliberate outcome before the venue call, through `decideOperatorSharedCloseFloor`.
  It escalates to a whole-position close (`market_close(sz=None)`) only when every peer is flat both in its own book and on a freshly refetched on-chain account, on the same side, and no larger than the operator's own position — the same proof the automatic cycle floor requires.
  Every other case refuses and sends no close order (`"no close order sent"`), including an unreadable account or a failed refetch; a hand close never holds or defers the way the cycle does, and a stored cycle hold reason is never an input to the decision.
  The one exception is an unreadable mark price: it cannot be measured against the gate, so the decision withholds only the escalation and lets the sized close through unchanged, with a warning — the venue's own minimum still catches a truly small one.
  The sized `manual-close` (a `--qty` partial or a shared-coin full close) reads the on-chain account and runs `planHLCloseOrder` (reduce-only capped at the on-chain quantity, or the netted cross order when an opposite-side peer shares the coin); a skip, or an unreadable account with an opposite-side peer, refuses with no order sent, and `--dry-run` prints `sized reduce-only N` or `sized cross N`.
  `force-close` plans its sized close the same way (see above).
  On escalation the booked trade reflects the venue's actual fill (`Execution.Fill.TotalSz`), not the requested book quantity, so a short fill attributes only the virtual portion (fee pro-rated, a warning logged) and `IsFullClose` follows the fill rather than deleting a position that still holds a remainder.
  A refusal still cancels the strategy's resting limit orders as usual, and both the refusal text and the CRITICAL alert say the order itself was never sent, naming the cancelled ids as not restored.
  With a notifier present, a refusal sends the stranded-remainder alert once under the `operator_refused` label; with no notifier (command line against a stopped daemon) it prints the full reason.
  `manual-close --dry-run` and `force-close --dry-run` preview the escalate/refuse/sized-with-warning outcome and send no order.
  That preview reads the account when the shared-coin floor or the sized plan needs it; a sole-owner whole-position preview does not.
- External closes made on the UI, or by a stop or take-profit, are detected by the reconciler and cleared automatically.
- `type=manual` is exempt from circuit-breaker drawdown checks.

### Resting limit orders

```bash
./go-trader manual-open hl-manual-btc --limit-price 68000 --side long --margin 50 [--tif Gtc] [--expire-after 4h]
./go-trader manual-cancel <limit-order-id>
```

- `--tif Alo` (default, post-only) or `--tif Gtc` only. `Ioc` is rejected because it never rests.
- The CLI exits right after placing the order. The scheduler polls fill status each cycle and books fills incrementally; partial fills open the position and grow it under the same position ID.
- Protection is **not** placed inline. The next cycle after the first fill applies it, as for any manual position.
- `manual-cancel <id>` queues a cancel; the scheduler cancels on-chain and finalizes next cycle. Expiry and operator cancel share that path.
- Rows live in `pending_limit_orders`; each partial-fill leg is tagged `scale_in`, so `#T` counts one position however many fills it took.

### Scale-in / pyramiding

An opt-in way to **increase** an open position instead of the default skip-on-same-direction. Scope: Hyperliquid perps and manual, live and paper. A same-direction add blends only price and size for PnL (`AvgCost`, `Quantity`, `InitialQuantity` grow) and **freezes the original risk plan** — entry ATR, the regime label, and the trigger geometry stay pinned to the first entry, and the cleared-tier watermark is never reset. Only the on-chain protection **size** is re-based, at unchanged triggers, on the next protection sync.

- **Strategy flags (perps):** `allow_scale_in: true` plus an optional `scale_in` block — `max_adds` (0 = unlimited), `max_added_notional_usd` (0 = unlimited), `add_spacing_atr` (signed: `>0` adds to winners, `<0` averages down, `0` no gate, measured in entry-ATR multiples from the last leg), `add_notional_usd` (0 = the standard open notional). It fires only when a same-direction signal actually reaches Go. A strategy with `open_strategy` or `close_strategy` set holds every open signal while a position is open, so it takes no adds from signals. `manual-add` accepts only `type: manual`, so such a `type: perps` strategy has no add path while a position is open.
- **CLI (manual):** `manual-add <strategy-id>` takes the same sizing flags as `manual-open`. Side is inferred, it refuses when flat, and the kill-switch and pending-breaker guards apply.
- An add books as `trade_type=scale_in` on the same position ID and is excluded from the `#T` open count, so `#T` stays distinct positions. Win/loss is unaffected.
- **Live perps guard:** `allow_scale_in` needs an ATR, regime, or trailing stop — one the resize path can grow. A static scalar stop is rejected at load because it would under-cover the grown position. Manual auto-uses an ATR stop, so it qualifies.
- Hot-reloadable when flat; toggling either field while open is blocked. Backtestable; add legs simulate against the frozen risk anchor, not the blended average cost.

---

## Backfill HL Fees

Hyperliquid `exchange_fee` was $0 for trades placed before exact-fee resolution shipped.

```bash
./go-trader backfill hl-fees --all                    # dry run
./go-trader backfill hl-fees --strategy hl-btc-momentum
sudo systemctl stop go-trader
./go-trader backfill hl-fees --all --apply
sudo systemctl start go-trader
```

- `--apply` refuses while another `go-trader` process is alive.
- Close-leg `realized_pnl` is adjusted by `(modeled_fee − real_fee)`.
- `strategies.cash` is replayed from `initial_capital` on the corrected fee and PnL stream.
- A cash-replay divergence over $1 (usually a SIGHUP capital top-up) is a warning and blocks `--apply` unless `--reset-cash` is passed.
- Paper-mode HL strategies, `perps` and `manual` alike, are skipped (no real order IDs). Live manual strategies are included.
- Per-row skip reasons are reported: `missing_oid`, `no_fill_match`, `already_real_fee`. Rows already on the gross convention are skipped as `gross_convention_row` — they belong to the trade-ledger backfill below.

---

## Backfill Trade Ledger

Migrates legacy trade rows to the gross-PnL convention and trues fee, price, and PnL up to Hyperliquid `userFills`, so the shared-wallet ledger display path reads exchange-accurate values.

```bash
./go-trader backfill trade-ledger --all               # dry run
sudo systemctl stop go-trader
./go-trader backfill trade-ledger --all --apply
sudo systemctl start go-trader
```

- Two chronological passes per row. Pass one migrates legacy net rows to gross: the fee deducted at booking (the stored real fee, else the modeled taker fee) is stamped into `exchange_fee`, the close leg gets it added back to `realized_pnl`, and `fee_source` records provenance. Rows whose `fee_source` is `reconcile_adjustment` skip migration and the userFills true-up, but cash replay still includes them. Pass two gives every row whose order ID matches a userFills aggregate the real fee, the fill VWAP price, and the exchange gross closed PnL.
- Rows sharing one order ID (partial take-profit legs, flip close-and-open pairs, shared-coin aggregate fills) apportion the aggregate by quantity share: fee across ALL legs, closed PnL across close legs only.
- `strategies.cash` and `closed_positions` replay under net semantics, with the same `--reset-cash` divergence gate as the fee backfill.
- Live funding rows are never rewritten and never touch cash. Paper funding rows do change paper cash (Paper Funding Accrual below), so this tool, like the fee backfill, skips every non-live HL strategy, `perps` and `manual` alike.
- `--apply` resets every shared wallet's ledger drift baseline, so the next reconciled cycle re-anchors on the repaired ledger instead of alarming on the correction.
- Idempotent: a second run over the same fills reports zero changes.

---

## Paper Funding Accrual (HL perps)

Paper Hyperliquid `perps` and `manual` strategies (partition not `live`) pay or receive venue funding, as live does through the wallet ledger. Live wallet funding (`fetchWalletLedgerEvents`, `ingestFundingEvent`, `funding_orphan`, the watermark) is unchanged; a paper strategy never enters it and a live strategy never gets paper funding state. Eligible coins are the primary coin and `hedge.symbol`.

- **Exposure rule.** Accounting time is the wall clock at which the in-memory paper book changes (`paperFundingClock`), never a trade-row timestamp (replay rows carry decision times). A record at venue time `t` charges the signed book quantity held at `t`; a change at exactly `t` counts as after `t`; intervals are `(From, To]`. This matches the simulator, which charges the position carried into a bar before its open fills.
- **Inventory helper.** Every paper HL perps/manual book change goes through `mutatePaperPerpsBook`, which closes the exposure segment before the change and re-anchors after it (`hyperliquid_paper_funding_state.go`). Every helper call and every accounting run checks the anchor against the book; a mismatch becomes an explicit unknown window with a CRITICAL alert, and a record inside it is held, never guessed. The helper is a no-op for live strategies.
- **Valuation.** The first cycle mark at or after the record time (sampled during accounting runs at least once after each clock hour and every 5 minutes, positive and finite). The row records the mark, sample time and lag.
- **Coverage.** Feed modes read accounting funding only from the sealed snapshot (seal v3, below) and never fetch; `market_feed: rest` fetches bounded `fundingHistory` pages per coin at most every 5 minutes, outside `mu`. Coverage `{from_ms, to_ms}` asserts every record in that range is present, strictly ordered, finite and conflict-free; conflicting duplicates are refused before deduplication.
- **Settlement.** Per coin, `settled_through_ms` advances only through covered time. A transient blocker (no mark yet, storage error, coverage that starts after progress while exposure is open, conflicting or under-30-minute records) stops the coin with no progress. A record in an unknown window or with a non-finite payment becomes a visible held item. A spacing over 90 minutes while a position was held is a recorded gap; a late record inside it books once. A missing record never books as zero; a confirmed zero rate settles with no row.
- **Row.** `Side: funding`, `TradeType: funding`, `PnLGross`, `RealizedPnL` = `-signedQty * mark * rate`, no fee, `ExchangeOrderID` `paper_funding:<COIN>:<t ms>`, `Timestamp` = `t`, `PositionID` = the captured identity (never the position open at settle time), quantity, price and value 0. Cash and the row are applied with eager persist suspended, so the partition save commits cash, rows and `strategies.paper_funding_state` together.
- **Never a risk input other than cash.** Funding never touches `RecordTradeResult`, `RiskState.DailyPnL`, consecutive losses or circuit-breaker state, and is not gated by the kill switch, circuit breaker, pause, notional cap, daily-loss hold or persistence hold. It runs each due cycle after marks merge and before the save-blocked branch, so `measureScopeCycleRisk` sees the new cash in the same cycle; a partition whose saves are blocked gets observation and sampling only. Operator trade counts exclude funding rows.
- **Lifecycle.** The first sync starts at the clock with every open position anchored and no historical replay. A strategy that becomes ineligible drops its state with one log line. Unreadable stored state is held, alerted CRITICAL and saved back unchanged, never reset. Eligible paper cash may go negative and survives restart (`ValidateState` skips the clamp for it).
- **Pair members.** A research pair member (`pair_id`) is never a legacy funding owner (`pairFundingOwned`): sync, planning, accounting and the feed accounting coins skip it, and its venue funding belongs to the pair lane (Research pairs, Pair funding).
- **Alerts.** One owner DM per episode per strategy, coin and kind (`records_unavailable` after 2 hours of lag, `backlog`, `anomalous_records`, `mark_missing`, `storage_error`, `not_served`, `corrupt_state`); events alert each time (`gap`, `held_unknown_window`, `held_non_finite`, `helper_bypass`, `dedup_inconsistency`). Backlog older than the 7-day feed window stays pending; the remedy is one run with `market_feed: rest` (restart required).

---

## Trade Diagnostics

```bash
./go-trader diagnostics                    # all strategies
./go-trader diagnostics --strategy hl-btc-momentum
```

Every full close eagerly inserts a `trade_diagnostics` row at close time; a background worker fills in MFE, MAE, and capture ratio from hold-window OHLCV afterwards. That worker never blocks or alters a close — a failure just leaves those columns NULL and downgrades `metrics_status`. The report opens the state DB read-only, aggregates NET PnL per strategy through the trades join (so tiered take-profits and partial exits sum correctly across legs), splits by regime-at-open and direction, and prints sample-size-gated hypotheses with the exact backtest command to validate each one. Synthetic closes (`hl_sync_external`, `*_corrupt`, `*_dup_oid`) are excluded. `llm_verdict` shows per row when present, but only the LLM entry-analysis pipeline ever writes it.

---

## Backtesting

Run every backtest through `uv run --no-sync python`. Harness map: [`docs/backtesting-registry.md`](docs/backtesting-registry.md).

Hyperliquid perps runs charge hourly funding by default (`--funding charge`). The shared helper attaches right-closed accrual (a funding time is in a bar when it is after the previous bar open and at or before that bar). Missing-hour counts use that same bar for every bar width. The engine refuses an incomplete charge result before it saves. Compare and multi-asset record a refused strategy and still run the others, then exit 1. A frame that does not charge funding is stored as `not_priced` (`off` stays `off`). `--funding partial` keeps a flagged result. `--funding off` reproduces the older directional result: cash still includes `delta_neutral_funding` accrual, and per-trade statistics stay on price PnL.
A cache hit also refills finalized hours that have no stored rate inside a covered interval, by a strict Hyperliquid download of each contiguous run (#1747).
Only a complete download with ascending, contiguous pages and a print on both sides of an hour records that hour as venue-absent in `funding_venue_gaps`, and charge counts a marked hour as present at zero accrual.
A failed download writes nothing and reports `fetch_error`.
M1 noise, the fee audit, and Monte Carlo take that same option and record `funding_incomplete`. M1–M6, `auto_suggest`, and `tune_live` stay suggest-only.

```bash
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --mode single|compare|multi|optimize
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h --since 90

# Close evaluator — one close per strategy; --close-strategy takes a bare name or a JSON ref
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h \
  --close-strategy '{"name":"tiered_tp_atr","params":{"tp_tiers":[{"atr_multiple":1,"close_fraction":0.5},{"atr_multiple":2,"close_fraction":1.0}]}}'

# Backtest a live strategy verbatim (single mode only) — pulls its open + close refs from live config
uv run --no-sync python backtest/run_backtest.py --config scheduler/config.json --strategy hl-btc-momentum \
  --symbol BTC/USDT --timeframe 1h --mode single

# Regime gate — blocks entries outside the allowed labels; closes always execute
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h \
  --regime-enabled --regime-period 14 --regime-adx-threshold 20 --allowed-regimes trending_up trending_down

# Joint open × close-stack walk-forward co-optimization (backtest-only)
uv run --no-sync python backtest/run_backtest.py --strategy momentum --symbol BTC/USDT --timeframe 1h \
  --mode optimize --sweep-close --optimize-metric sharpe_ratio|total_return_pct|dd_adjusted_return

uv run --no-sync python backtest/backtest_options.py --underlying BTC --since 90 --capital 10000
uv run --no-sync python backtest/backtest_theta.py --underlying BTC --since 90 --capital 10000
```

- `--config` needs `config_version` 15 or newer, applies `user_defaults` by default, and `--defaults system` keeps the built-in baseline instead.
- Stop-versus-take-profit races default to `ohlc_walk`; `--intrabar-resolution bar_close` restores the legacy behavior.
- A backtest whose equity hits 0 prints a LIQUIDATED banner and floors return and Sharpe at −100%, so a deeper blowup can never rank above a shallower one.
- The backtester rejects HL-live-only mechanisms: `regime_window_divergence`, `tiered_tp_atr_live_regime_dynamic` (through the close-capability policy below, in every consumer and both modes), and an enabled `hedge` block.
- With `platform=hyperliquid` (a `--config` run of a Hyperliquid strategy defaults to it), tiered take-profits follow the resting-limit model: tiers are priced from the entry ATR, the risk anchor and the position regime, a tier crossed at the bar close fills in that bar at the tier price (quantity-weighted across tiers crossed together) with no slippage and the tier fee, and a non-increasing ladder is rejected at init. Other platforms keep the legacy next-open fill.
- Tier fee precedence (`Backtester(maker_fee_pct=...)`, issue 1726): the execution spec `maker_fee_pct`; else an explicit `maker_fee_pct`; else an explicit `commission_pct` (so `commission_pct=0` charges zero on tier fills); else the platform taker rate. `execution_spec` plus `maker_fee_pct` raises (charged twice). The default is taker, because the fee evidence did not classify live tier fills as maker. `backtest_pairs.py` takes its taker and maker defaults from `PLATFORM_FEE_PCT["hyperliquid"]` and `HYPERLIQUID_MAKER_FEE_PCT`, so a pairs run that omits `--taker-fee` now charges 0.00045 (was 0.000432).
- Research surfaces (`tune_live.py`, auto-suggest, regime promotion, the `/tuning` page) are **suggest-only**. They never write live defaults, config, or PRs.
- `--config` on a Hyperliquid perps strategy sizes with the live rule: sizing cash starts at initial capital, an open subtracts only the fee, free margin is sizing cash minus used margin, and `margin_per_trade_usd` notionals are `min(margin, sizing cash) × exchange leverage`. `sizing_leverage` below 1 still sizes. Callers that do not pass `perps_sizing` keep the previous cash-fraction fills. `--liquidation-model venue_isolated` needs `--manifest` and `ohlc_walk` (it refuses `bar_close`). The isolated liquidation price is anchored to average entry and the live quantity's margin. A bar-open stop move does not suppress a range liquidation. It still cannot pass a strict comparison until a recorded liquidation price verifies the formula. A shared-wallet pool with no per-entry available margin is labeled `pool_cap_unverified` on a research run and refused by the strategy-simulation preview. Live and paper order execution are unchanged.

### Close comparison mode (#1683)

`backtest/backtester.py` `validate_close_capabilities` is the one close-capability policy for the common backtest engine. The `Backtester` constructor calls it right after it normalizes close refs and before the Hyperliquid resting-ladder check, so the named refusal always fires first. `load_strategy_config` (after user close defaults resolve), the runner, `eval_windows.py`, `parity_diff.py` (decision walk, before strategy evaluation), `exit_diagnostics.py`, `exit_policy_ab.py` (arms and entry replay), the regime economic gate and `shared_scripts/simulate_strategy.py` all reach it. No other close-name list decides engine eligibility.

- **Mode.** `comparison_mode` (`Backtester` kwarg, candidate/research JSON field, `--comparison-mode` on `run_backtest.py`, `eval_windows.py`, `parity_diff.py`, `exit_diagnostics.py`, `exit_policy_ab.py` and the 983/984 `sweep_supplementary.py`; m6 block or variant `comparison_mode` in an auto-suggest spec). Omitted means `strict` on every platform. Only the exact strings `strict` and `approximate` are valid; anything else is `INVALID_COMPARISON_MODE`. Conflicting explicit selections fail. Live configs carry no mode field.
- **Strict** refuses `time_stop` (needs `bars_held`) and `zscore_target` (needs `zscore`) with `UNSUPPORTED_LIVE_CONTEXT`, because no live check script, shared tool or scheduler path supplies those inputs, so the live evaluators no-op. **Approximate** admits them with the simulator's own inputs and records a `RESEARCH_ONLY_CLOSE_CONTEXT` approximation. `tiered_tp_atr_live_regime_dynamic` is `LIVE_ONLY_CLOSE` in both modes. An unregistered name is `UNKNOWN_CLOSE_STRATEGY` in every regime branch; a registered close missing from `CLOSE_CAPABILITIES` is `UNCLASSIFIED_CLOSE_CAPABILITY`. `INVALID_CLOSE_REFERENCE`, `UNSUPPORTED_REPLAY_CAPABILITY` (entry replay only) and `INVALID_CAPABILITY_CONTEXT` complete the codes.
- **Errors.** `CloseCapabilityError(ValueError)` carries `reason_code` (first refusal in stable check order), `reasons` (every finding), `validation`, and `to_dict()`. Each record has `reason_code`, `feature`, `close_ref_index`, sorted `required_inputs`, and `details` (with the human `message`, e.g. "Unknown close strategy: …"). Refusals stop the run with the named reason: `run_backtest.py` exits 1 and prints the serialized refusal; `simulate_strategy.py` returns the per-label `error` plus `close_capability`, empty `markers`, exit 1, so the dashboard tuner preview shows the named reason; `tune_live.py` marks the strategy `close_capability_refused` and the artifact `requested_set_complete: false`; auto-suggest marks an M6 variant `excluded_close_capability`, flags the shortlist INCOMPLETE and exits 1. No consumer retries without the close or switches mode.
- **Result contract.** Every engine result carries `close_validation` (schema 1): `mode`, `close_eligibility` (`eligible`, `approximate`, `refused`), `approximations`, `incomplete_parity` (always true in approximate mode), `parity_status` (`unverified` for accepted strict, `incomplete` for approximate, `refused`) and `refusals`.
  Accepted strict validation proves close eligibility only; it never states overall strict parity, which needs history, coverage, execution and accounting proof elsewhere.
  `decode_close_validation` turns missing, unknown-schema or inconsistent metadata into `close_eligibility: unknown`, never strict evidence.
  `aggregate_close_validations` keeps every child's modes, approximations and refusals; an unknown or refused child sets `requested_set_complete: false`, so a supported winner cannot hide a limited comparison set.
  Windows, optimizer summaries (per stack and grid), parity frames (`frame.attrs`) and summaries, diagnostics, M6 payloads, Monte Carlo payloads, auto-suggest evidence and study reports carry it.
- **Parity.** `parity_diff.py` reports `clean` only for zero mismatches with complete close validation. Approximate zero-mismatch output is `decision_agreement` with `close_parity: incomplete` and exit code 3, because the decision walk and the live path both omit held bars and zscore.
- **Saved results.** `shared_tools/storage.py` adds nullable `backtest_results.close_validation_json` through an idempotent column-presence migration. Older rows keep NULL and decode as unknown.
- **Extension point.** `CLOSE_CAPABILITY_CHECKS` is a fixed source-owned tuple of `CloseCapabilityCheck(check_id, phases, reason_codes, callback)`; callbacks return `CapabilityRecord` findings and never raise. `CapabilityContext` carries `raw_fields` (`{present, value}`), `resolved_stop_owner` (`{name, parameters}`) and `input_evidence` (`{status, source, value}`). #1684 adds `stop_owner_support`, `stop_owner_inputs` (also registered for the `runtime` phase) and `stop_margin_leverage` with their own reason codes; there is no second refusal path or exception type.

### Backtest stop geometry (#1684)

The common engine reproduces the live Hyperliquid perps stop geometry. Live functions stay the reference: `effectiveTrailingStopPct`, `effectiveFixedStopLossATRPct`/`fixedStopLossATRTriggerPx`, `EffectiveStopLossPct`/`percentStopLossTriggerPx`, `effectiveTrailingStopMinMovePct`, `computeTrailingStopUpdateInternal` and `PerpsRiskStopDistance`.

- **Units.** `Backtester` percent fields (`stop_loss_pct`, `stop_loss_margin_pct`, `trailing_stop_pct`, `max_drawdown_pct`, `trailing_stop_min_move_pct`) are fractions for direct callers. Live percent values convert exactly once, in `run_backtest.load_strategy_config` (live config) or `live_stop_engine_inputs` from `simulate_strategy.py` (tuner preview, which must send `stop_units: live_percent`). The returned `capability_context` records the source; there is no magnitude guess.
- **Defaults.** The loader follows the live order: the user ratchet regime trail, then the scalar `default_stop_loss_atr_mult` (HL perps with no stop field, no configured regime block and no unified close), then user `tp_tiers`, then user `regime_atr`. It resolves `max_drawdown_pct` (strategy, platform risk, type default) and `leverage` (perps default 1, recorded as unverified). Raw `{present, value}` evidence is captured before defaults, so an explicit zero stays distinct from an absent field.
- **One owner.** On HL perps the engine arms the first owner live arms: trailing (post-profit override, `trailing_stop_pct`, `trailing_stop_atr_mult`, regime trail), then fixed ATR (unified close, scalar, regime, in that order), then the percent stop (`stop_loss_pct`, `stop_loss_margin_pct` divided by verified leverage, then the `max_drawdown_pct` fallback capped at 50%). An explicit `trailing_stop_pct` or `stop_loss_pct` of 0 disables the stop, as live does. ATR distances are anchor percentages capped at 50%. Both the plain-signal and open/close paths use it, with the frozen risk anchor across scale-in and average cost only for PnL.
- **Trailing.** Each bar moves the high-water mark; the trigger is replaced only when the move is at least `trailing_stop_min_move_pct` (default 0.5%), except the bar a ratchet tier tightens the trail. `trail_from_here` seeds the absolute ATR trigger at the bar mark, then trails as an anchor percentage. Live's one-shot widen is manual-adoption only and never arms in a backtest.
- **Refusals.** `UNSUPPORTED_STOP_OWNER` (owner conflicts, unified sole-owner admission, ratchet requirements, margin-only post-profit rules, risk sizing without a sizing-grade owner), `MISSING_STOP_INPUT` (invalid regime blocks, a `regime_atr_window` whose label column was not prepared — the missing column is named and is not read from the primary column — and at runtime an entry whose entry ATR is missing or implausible after ATR history exists, a regime label that does not resolve, or an ATR post-profit rule without entry ATR) and `UNVERIFIED_MARGIN_LEVERAGE` (a margin owner without verified leverage).
  Approximate mode never waives them.
  An entry during indicator warm-up (no ATR or regime label history yet) is skipped and counted in `stop_warmup_skipped_entries`, and a seeded position without those inputs starts flat (`stop_seed_dropped`), so no position runs unprotected.
  A regime-label owner with no label source at all (`regime_enabled` false and no `regime` column) refuses with `MISSING_STOP_INPUT` before the bar loop; it is never treated as warm-up.
  At the live-config boundary a unified close beside any present scalar stop field, including an explicit zero, is refused like live; direct callers keep the positive-value rule.
- **Transport.** `parity_diff.py` (`ParityConfig.stop_kwargs`), `exit_policy_ab.py`, `optimizer.walk_forward_optimize(stop_kwargs=)`, `eval_windows.py` (candidate `stop_context`), `tune_live.py`, `ledger_compare.py` (historical segments, both bases) and the tuner preview (`leverage`, `leverage_source`, `max_drawdown_pct`, `trailing_stop_min_move_pct`, `regime_atr_window`, `regime_gate_window`, `regime_directional_window`, `regime_gate_on_failure`, `regime.timeframe`, `regime.windows`, `regime_directional_policy` and its certified states) carry the full context.
  M6 stamps regime labels on any arm whose resolved owner needs one, even with no `allowed_regimes`.
  The preview applies the translator's window rule and label vocabulary; `leverage_source` is `strategy_config` (verified), `loader_default` (the Go loader defaulted it, unverified) or `tuner_override` (verified).
  Each arm reports its own refusal in `live_refusal` or `simulated_refusal` with HTTP 200, and the preview note quotes it; the other arm still renders.
  The preview applies `regime_directional_policy` with the certified states of the daemon's loaded store.
  A `regime.timeframe` that differs from the chart timeframe refuses any arm that reads a regime label (gate, named window, directional policy, or a regime stop or tier owner), because the preview does not fetch regime-timeframe candles.
  `tune_live.py` reports `stop_owner` in live units with `stop_owner_units`.
- **M6 candidate stops (#1690).** `candidate_stops` is `inherit` (default), `drop`, or an object with exactly one finite positive `stop_loss_atr_mult` or `trailing_stop_atr_mult`. The object replaces every inherited stop-owner selector on the candidate arm; it never merges, so a requested ATR stop always owns the stop even when the incumbent had a percent stop. Platform, type, leverage, drawdown and trailing-move inputs and their evidence stay; the context drops the incumbent's declared owner and records `candidate_stop_override`. With no candidate close it is stop-only: no signal-reversal exit, paired replay runs. Auto-suggest never sends a stop field as a close ref.
- **Oracle.** `backtest/testdata/stop_geometry_parity.json` holds paper-mode configs, positions, mark paths and the expected outputs. `scheduler/stop_geometry_parity_test.go` asserts the real Go functions (`STOP_GEOMETRY_FIXTURE_UPDATE=1` rewrites `expected`); `backtest/tests/test_stop_geometry_parity_1684.py` asserts the translator and `Backtester.run` through the `stop_observer` hook. Geometry agreement is not historical, execution or liquidation parity. Manual-type strategies and non-Hyperliquid platforms keep the legacy geometry.

---

## Reconfiguration

```bash
sudo systemctl kill -s HUP go-trader   # hot reload, no state loss
sudo systemctl restart go-trader       # full restart
```

Hot reload re-applies a safe subset: capital, drawdown, intervals, params, stop-loss fields (including percentage and ATR-multiple trailing), sizing leverage, theta harvest, `portfolio_risk` knobs and their `portfolio_risk.paper` overrides except `max_notional_usd` on either, summary cadence, per-strategy `allowed_regimes`, `paused`, `circuit_breaker` and its cooldowns, `notify_ratchet_triggers`, paper `allow_no_edge`, `llm_entry_analysis`, `hurst_gate`, `alert_throttle_interval`, `kill_switch_reset_dm_timeout`, `log_level`, `user_defaults`, `tuning.max_retained_runs`, and the Discord/Telegram **channel maps**. Per-strategy `regime_*_window` selectors, `replay_sharing` and `replay_source_id` reload only while flat.

**Restart-required — a SIGHUP is rejected outright:** the strategy roster;
any `script`/`args`/`type`/`platform` field, the HTF filter, or kill-switch identity;
`db_file`;
`log_dir`;
`status_port`;
the status token;
`auto_update`;
`paper_db_file`, every `paper_sources` entry and its `db_file`, any strategy's `paper_source`, and any strategy's effective `storage_strategy_id`;
`risk_free_rate`;
`leaderboard_post_time` and `leaderboard_summaries`;
`tradingview_export`;
`replay_log_path`;
`market_feed`, `role` and both `shared_market_feed` socket paths (a feed service rejects `feed.socket_path`, `feed.source` and `status_port` changes the same way);
`portfolio_risk.max_notional_usd`, `portfolio_risk.paper.max_notional_usd` and every `paper_sources[].portfolio_risk.max_notional_usd`;
the whole `baskets` block and any strategy's `basket_id` (§ Basket Coordinator);
the whole `correlation` block;
the global `regime` block (enabled, period, adx_threshold, windows);
`discord.enabled`/`token`/`owner_id` and `telegram.enabled`/`bot_token`/`owner_chat_id`;
and a shared-wallet pool↔allocated budgeting switch.

It also refuses when per-strategy exchange `leverage`, `direction`, `invert_signal`, HL `margin_mode`, or the regime timeframe changed while a position is open, and for every field in the blocked-while-open list under Post-Update Agent Protocol. It re-runs the HL peer-on-same-coin check for `margin_mode` and exchange `leverage` agreement and the single-trailing-stop-owner rule, and re-validates every `hedge` block. A rejection names every offending field at once; fall back to a restart.

Common changes:

- Regenerate config: `./go-trader init`, or the scripted `--json` form.
- Channels: edit `discord.channels` / `telegram.channels`; use `trade_alert_channels` to send fills somewhere other than the summaries.
- Token: `sudo systemctl edit go-trader`, add the environment override, restart.
- Add or remove strategies: edit the `strategies` array. Removed strategies are pruned from state.
- Risk: edit strategy `max_drawdown_pct`, portfolio `max_drawdown_pct`, `portfolio_risk.warn_threshold_pct`.
- Paper to live: change `--mode=paper` to `--mode=live`, add `--execute` where required, and configure the exchange credentials.

Changing `capital` does not reset cash or positions. For a full reset, remove `scheduler/state.db` (or that strategy's rows) and restart.

---

## Adjustable Settings

Global — key, default, notes:

| Key | Default | Notes |
| --- | --- | --- |
| `interval_seconds` | 300 | Check interval |
| `db_file` | `scheduler/state.db` | State DB. In the split layout it holds the live scope, process metadata, the live-only wallet/cash-flow tables and shared regime history. **Restart-required.** |
| `paper_db_file` | absent = single file | Optional second state file owning the paper scope's books, risk row, kill-switch events and correlation snapshot. Must resolve to a different physical file than `db_file`. **Restart-required.** |
| `paper_sources` | absent | Folds several paper deployments into one process, each in its own partition `paper:<id>` and its own file. Each entry carries `id`, `db_file` and an optional `label` and `portfolio_risk` override; every path must be distinct from every other state file. A paper strategy joins one with `paper_source`. **Restart-required.** See § Storage Ownership. |
| `storage_strategy_id` (per strategy) | `id` | The identifier this strategy owns inside its state file. Unique per file; the same value in both files is the supported alias. Lets a strategy `id` be renamed with no stored rewrite. **Restart-required.** |
| `auto_update` | `off` | `off` \| `daily` \| `heartbeat` |
| `status_port` | 8099 | Loopback only |
| `risk_free_rate` | 0.04 | Sharpe basis |
| `max_drawdown_pct` | 25 | Portfolio kill switch, per scope (live and paper latch independently) |
| `portfolio_risk.warn_threshold_pct` | 60 | Percent of the kill-switch limit |
| `portfolio_risk.max_notional_usd` | `0` = off | Gross notional cap. Over it, opens/adds/flips are held and manual open/add/limit-open refuse; closes, reductions and protection keep running. **Restart-required.** |
| `portfolio_risk.daily_max_loss_usd` | `0` = off | Cap on the day's aggregate PRE-FEE realized loss. Hold-only until UTC rollover; nothing is force-closed. Hot-reloadable even while tripped. Ignored inside `platforms.<name>.risk`. |
| `portfolio_risk.daily_max_loss_pct` | `0` = off | Same limit as a percent of Σ per-strategy `initial_capital`. With both arms set the lower resolved USD threshold wins; a zero-capital basis cannot evaluate and `/status` says so. |
| `portfolio_risk.max_same_direction_notional_usd` | `0` = off | Blocks a new same-direction open over the cap. Blocking only, direction-aware. Hot-reloadable. |
| `portfolio_risk.max_asset_concentration_pct` | `0` = off | Same blocking behavior scoped to one asset's share of exposure. Shares its exposure model with `correlation.*`. |
| `portfolio_risk.paper` | absent = inherit | Optional override block with the same fields, applied to the paper scope only. A zero or omitted field inherits the parent value; a nested `paper.paper` is rejected. `paper.max_notional_usd` is restart-required; the rest hot-reload. |
| `portfolio_risk.include_paused_in_warning` | `false` | When true, a paused strategy with no open position (regular or option) is still counted in the portfolio warning's Top Contributors block and lead-attribution line. Default excludes flat paused strategies and appends an excluded-count footnote; a paused strategy with an open position is always shown. Layers root > `paper` > `paper_sources[].portfolio_risk` like the other fields; `false` never turns an enabled layer back off. Hot-reloadable. |
| `alert_throttle_interval` | 6h | Go duration. Coalesces repeat operator alerts. |
| `kill_switch_reset_dm_timeout` | empty = 6h | Go duration. How long the reset prompt waits. Independent of `alert_throttle_interval`. |
| `log_level` | `"info"` | `"info"` or `"debug"`; any other value fails `loadConfig`. `info` prints trades, fills, non-HOLD signals, warnings, errors, CRITICAL alerts, risk and circuit-breaker events, and one `Cycle N complete` line per cycle. State lines print once and then again only when their state changes: regime labels, the cash-flow journal basis, the feed health, the per-cycle protection lines (`HL protection synced`, `HL manual protection synced`), the directional policy, divergence, the regime profile, the market-closed state, the update check, and each strategy's `Status:` line (positions, regime, markers; always on a trade). A protection sync after a trade, a limit fill, or a failed close prints every time. Script stderr prints without the known progress lines (`Fetching ...`, `Funding rate ...`, `Funding history ...`, `Merged pair ...`). A failed check prints its command with each argument over 256 bytes shown as its size. `debug` restores the full output: cycle headers, `Prices:`, every `Running: python3 ...` argv, HOLD signals, progress stderr, per-batch timing, and every state line each cycle, tagged `[DEBUG]` on the per-strategy lines. The level applies to stdout (the journal) and to `logs/<id>.log` alike. Hot-reloadable. |
| `correlation.enabled`, `.max_concentration_pct`, `.max_same_direction_pct` | off, 60, 75 | Warnings to all active channels plus an owner DM; snapshot in `/status`. Restart-required. |
| `summary_frequency` | see Configure | Per-channel cadence |
| `regime.enabled`, `.period`, `.adx_threshold`, `.windows`, `.gate_on_failure`, `.transitions` | off, 14, 20, empty | Empty `windows` = one legacy horizon. Restart-required as a block. `transitions` is alerting-only — it never gates entries, mutates config, or touches positions. |
| `notify_tp_sl_fills` | enabled when nil | `false` stops owner DMs from reconciler-detected fills |
| `notify_ratchet_triggers` | enabled when nil | Owner DM when a ratchet tier clears and tightens the trail. The per-strategy field overrides it. |
| `market_feed` | `"rest"` | `"rest"` (default; an omitted field means this) keeps legacy per-check polling. `"websocket"` opens one Hyperliquid socket and hands Hyperliquid perps and `manual` checks a sealed market snapshot on stdin. `"shared"` reads that snapshot from a separate feed service (§ Shared market feed). Any other value fails `loadConfig`. **Restart-required.** § Hyperliquid Batched Signal Checks. |
| `shared_market_feed` | unset | `{"primary_socket": "/run/go-trader-<feed>/feed.sock"}`, required with `market_feed: "shared"` and rejected with any other mode. The path must be absolute, clean and at most 100 bytes. Optional `backup_socket` names the REST backup feed; it follows the same path rules and must differ from the primary. **Restart-required.** § Shared market feed. |
| `role` | `"scheduler"` | `"feed"` turns the file into a market feed service config (§ Shared market feed); scheduler commands refuse a feed config by name. **Restart-required.** |
| `atr_method` | `"simple"` | `"simple"` (legacy rolling mean, ≥100 rounding) or `"wilder"` (published RMA, never rounded). Governs the standard ATR surface only — entry-ATR stamping, live market ATR, the manual fetch, backtester injection, tuner simulate. Strategy-internal indicators and the regime classifier are untouched. |
| `default_stop_loss_atr_mult` | `1.0` | Applies to every HL perps strategy omitting all stop-owner fields, shared-coin peers included. `0` restores the `max_drawdown_pct` fallback fleet-wide. |
| `user_defaults.manual.{margin_usd,stop_loss_atr_mult,side,tp_tiers,trailing_stop_atr_mult_regime}` | see Manual Trading | Overrides the manual-open defaults. Order: CLI or strategy param → `user_defaults.manual` → constant. `stop_loss_atr_mult: 0` opts scalar manual out and the ratchet fallback ignores that 0. `tp_tiers: []` is rejected — omit the key to inherit. Hot-reloadable. |
| `user_defaults.close`, `user_defaults.regime_atr` | none | `user_defaults.close` injects `tp_tiers` into matching close refs that omit them; its `trailing_tp_ratchet_regime` entry may carry a coupled `trailing_stop_atr_mult_regime`. `user_defaults.regime_atr` supplies fleet-wide `stop_loss_atr_mult_regime` / `trailing_stop_atr_mult_regime` for standalone `use_defaults` owners. Three layers — system → user → strategy, explicit wins. Hot-reloadable. |
| `tuning.max_retained_runs` | `0` = keep all | Prunes oldest-first terminal research-run directories; never touches a queued or running row. Hot-reloadable. |
| `replay_log_path` | `""` = off | Shared SQLite path for live→paper decision replay. It must live outside every deploy tree, e.g. `/var/lib/go-trader/shared/replay.db`, which the template unit grants through `StateDirectory=go-trader/shared`. Required by `replay_sharing`. **Restart-required.** |

Per-strategy:

| Key | Scope | Notes |
| --- | --- | --- |
| `capital` | all | Starting capital reference |
| `max_drawdown_pct` | all | The strategy circuit breaker |
| `interval_seconds` | all | `0` uses the global; auto-accelerates inside the drawdown warn band |
| `circuit_breaker` | all but manual | `false` disables BOTH arms (drawdown and consecutive losses), live and paper; nil = enabled. It suppresses only NEW fires — a latched breaker or pending close still drains — and displayed drawdown still updates. One warning per suppressed breach, `cb=off` in the startup summary and `inspect`. Hot-reloadable even while open. |
| `cb_drawdown_cooldown_minutes`, `cb_loss_streak_threshold`, `cb_loss_streak_cooldown_minutes` | all but manual | Override the hardcoded breaker parameters; nil keeps 24h, 5 losses, 1h. Positive only; cooldowns ≤ 30 days, threshold ≤ 100. Hot-reloadable even while open, for new fires only — a latched expiry is untouched. |
| `paused` | all | `false`. Holds opens, adds and flips while closes, trailing stop, ratchet and protection sync keep running. Hot-reloadable always, including while open. Shows `⏸️ paused:` in Discord `/status`. |
| `allow_no_edge` | all but options | JSON boolean only. Required to admit an effective `no_edge` open or close-fallback reference outside explicit `--mode=paper`; an explicit `false` stays refused. Live use still warns. Adding or removing it on a live strategy is restart-required (SIGHUP refuses it before admission); paper changes hot-reload. Replaces `allow_deprecated`, which v20 migration removes. |
| `htf_filter` | HL perps, OKX, Binance.US spot, Robinhood, TopStep | Skips counter-trend signals (50-EMA trend on the default higher timeframe). Missing, short or unusable higher-timeframe input holds position-increasing signals while closes and protection continue, and repeated failures alert (§ Higher-timeframe filter). Restart-required. |
| `resting_tp_trade_through` | HL perps paper; opt-in | JSON literal `true` or `false`, default `false` (`null` and any other type are refused). A tier take-profit books only after a completed bar since entry traded at least one tick past the adapter-rounded limit, at that rounded limit; a touch never fills. Live is unchanged. **Restart-required.** § Resting Take-Profit Trade-Through. |
| `closed_bar_decisions` | Binance.US spot, OKX spot/perps, HL perps, Robinhood paper spot (Yahoo crypto pairs); opt-in | JSON boolean, default `false`. The signal, the exported entry ATR and entry sizing come from the last bar closed at or before the check's evaluation cutoff, as in the backtester; stops, trailing stops, ratchets and take-profits keep the current mark, ATR and regime. Missing or unverifiable closed history holds candle-derived opens and closes while protection continues. **Restart-required.** § Closed-Bar Decisions. |
| `open_strategy` | all | `{name, params}`; otherwise the name comes from `args[0]` |
| `close_strategy` | all | The single exit ref `{name, params}`; nil = open-as-close. A legacy `close_strategies` array of length ≤1 still parses, length >1 is rejected. |
| `direction` | perps | `"long"` (default), `"short"` (opens shorts only), `"both"`. Hot-reloadable when flat. |
| `invert_signal` | HL perps, manual | Inversion applies to the open signal only. A composed close is never inverted. HL checks send `invert_open_signal` on `--strategy-refs` and require the `open_signal_inverted` echo; a mismatch holds the signal. `type: manual` does not send the key. Composes with `direction="short"`. Blocked while open. |
| `stop_loss_pct` | HL perps | Sole owner auto-derives from `max_drawdown_pct` (cap 50) when omitted; same-coin peers need one explicit positive owner. `0` opts out. |
| `stop_loss_margin_pct` | HL perps | Leverage-aware. `0` opts out. |
| `stop_loss_atr_mult` | HL perps | Trigger at `avg_cost ± mult × entry_atr`, armed once after open. Live and paper. `0` restores the `max_drawdown_pct` fallback. |
| `trailing_stop_pct` | HL perps | Distance from the high-water mark. Live and paper. Capped at 50%; `0` disables. |
| `trailing_stop_atr_mult` | HL perps | `mult × entry_atr / avg_cost` frozen at open. Live and paper. Arms at open, once ATR exists. |
| `stop_loss_atr_mult_regime`, `trailing_stop_atr_mult_regime` | HL perps | Resolve the ATR multiplier per the position's frozen regime label. `{"trend_regime": {"<label>": {"atr": N}, …}}` or `{"use_defaults": true}`. Need `regime.enabled`. Backtestable. |
| `trailing_stop_min_move_pct` | HL perps | Minimum trigger move before a cancel-and-replace. Default 0.5%. A ratchet tier tighten bypasses it once, same cycle. |

**Exactly one of the seven stop owners** may be positive on an HL perps strategy: `stop_loss_pct`, `stop_loss_margin_pct`, `stop_loss_atr_mult`, `stop_loss_atr_mult_regime`, `trailing_stop_pct`, `trailing_stop_atr_mult`, `trailing_stop_atr_mult_regime` — plus the unified per-regime close block, which owns the stop itself and rejects every strategy-level stop field. Omit all of them and `default_stop_loss_atr_mult` applies. A nil↔positive toggle or a scalar↔regime flip is blocked while a position is open.

**Paper stops.** A paper HL perps position models the stop of its live twin for every owner above, the `max_drawdown_pct` fallback and the unified per-regime close.
The trailing owners use the trailing walker.
The fixed and regime ATR owners and the unified close use `fixedStopLossATRTriggerPx`.
The percentage owners use `riskAnchorPrice × (1 ∓ EffectiveStopLossPct / 100)`, the formula the live execute uses.
Paper arms the trigger at open from the booked paper fill (the fill is also the trailing high-water mark) and tests it against the mark in that cycle.
Each later cycle tests the stored trigger against the mark before signal handling, for any signal.
A breach closes the position (`trailing_stop_loss_paper`, `stop_loss_atr_paper` or `stop_loss_pct_paper`), and the signal of that cycle then runs against the flat book.
A paper stop books at the worse of the trigger and the mark (the lower for a long, the higher for a short), moved against the position by `SlippagePct` (see Paper fill cost model).
Paper triggers are not rounded to the venue tick, and the liquidation clamp is live-only.
As in live, the trailing walk and the arm of a missing trigger run only on `Signal == 0` cycles.
A paper position also moves its stop after a take-profit tier under `sl_after` (see that row).
A paper position under `tiered_tp_atr_live_regime_dynamic` also moves its stop when the applied regime label changes (`advancePaperDynamicCloseRegime`).
This runs only on `Signal == 0` cycles, after the new label is seen on `regime_confirm_cycles` hold cycles in a row (default 2), and before the `sl_after` move of that cycle.
The new trigger is `fixedStopLossATRTriggerPx` for the new label (risk anchor and entry ATR).
An unmarked stop moves, looser or tighter, only when the distance from the current trigger to the new trigger is at least `trailing_stop_min_move_pct` (default 0.5%) of the current trigger.
A stop already moved by `sl_after` (`sl_after_moved`) moves only when the new trigger is strictly tighter for the side and that same distance gate passes.
Pending confirmed take-profit consumption holds the applied label until that work is done; stop repair continues on the held label.
The stop does not move when no trigger is armed, when a trailing owner holds the stop (this includes an `sl_after` `trail_from_here` trail), when the close is a trailing take-profit ratchet, or when the new label gives no trigger.
When the mark has already crossed the moved trigger, the position closes in the same cycle (`stop_loss_atr_paper`) at the worse of the mark and the trigger.

| Key | Scope | Notes |
| --- | --- | --- |
| `sl_after` (on the close ref and/or per tier) | HL perps, manual | Post-take-profit stop move. Scalar modes: `"breakeven"`, `{atr_mult: N}` (signed), `{trail_from_here: {atr_mult: M}}`, `{trail_from_here: {tp_atr_fraction: F}}` where the trail is F × the firing tier's ATR multiple. Regime-aware shapes exist for each; composite labels follow `regime_atr_window`. Needs a fixed stop owner. Scalar↔regime or shape change blocked while open. The backtester has scalar parity and rejects the regime-aware shapes at init. **Paper HL perps** apply the same move (`runPaperPostTPStopLossAdjustment`, no subprocess): the cleared tier comes from the closed ratio `1 - Quantity/InitialQuantity` against the cumulative fractions of the position-regime tier ladder, with the backtester rule (highest tier at or above `SLAdjustedTiersProcessed` whose fraction is at most ratio + 1e-9). The move writes `StopLossTriggerPx` and advances `SLAdjustedTiersProcessed`; `trail_from_here` also writes `PostTPTrailingATRMult` and seeds `StopLossHighWaterPx` from the cycle mark, so the paper trailing walker owns the stop. Same signed trigger as live, no tighten-only clamp. An empty tier rule advances the watermark; an unresolved regime rule or an unarmed paper trigger defers. It runs in the same cycle after a paper partial close books (signal or replay mirror) and on hold cycles as catch-up, after that cycle's stop test, so the next cycle tests the moved trigger. Parity fixture: `backtest/testdata/sl_after_paper_parity.json` (Go and pytest). Paper `type=manual` is not wired. Unified per-regime closes (`tiered_tp_atr_regime`, `tiered_tp_atr_live_regime`, `tiered_tp_atr_live_regime_dynamic`) run `sl_after` only from confirmed consumption stored on the position (`tp_consumptions`). A venue discovery is not consumable. Live writes the booked record in the same book save as the quantity reduction. A discovered fill whose label still resolves is booked under that label, including when the applied label has since changed. A tier that fills as it is placed is recorded under the label of the plan that placed it. A discovered label that does not resolve stays deferred. Paper signal closes and replayed take-profit partials (`hl_sync_tp<N>_fill`, `hl_sync_external_partial`) record each tier whose fraction is crossed by that close. The processor takes the oldest pending label, runs the highest tier's rule in that group, and completes the group. An empty rule completes with no stop move. Missing or unresolved evidence stays `deferred`, is logged, sends one owner DM per position and cause, and does not hold regime advancement. A `booked` record holds dynamic regime advancement on live and paper until the group is done. `sl_after_moved` is set when the live replacement rests or fills, or when the paper trigger is written, and it remains until the position is deleted. A regime change does not loosen a marked stop and does not replace a trailing owner. A later label's `sl_after` rule also does not loosen a stop an earlier label's rule already moved: the group completes and the tighter stop stays. A rule under the same label can still move either way. An open unified position that is already partially closed and has no consumption records does not guess a historical fill: one diagnostic says earlier take-profit fills do not move the stop and that a manual stop edit is the remedy. Manual `trail_from_here` fails load and names the label and raw tier index. The label's `stop_loss_atr` is the fixed stop. The backtester runs the first two names from the open-time label and still rejects the dynamic close. Paper still tests the moved trigger on the next cycle. The paper tier close uses this same ladder: the final tier closes the rest, the tiers come from the position label (the dynamic applied label advances on paper hold cycles), and a paper tier fill books in the same cycle as this move. **Live HL perps** (`runPostTPStopLossAdjustment`) read the venue reply as the trailing walker does, through `applyTrailingStopUpdateResult`. `--update-stop-loss` modifies the resting stop in place when it is still open, so two full-size stops never rest together; an open old stop is modified in place and never cancelled; a modify rejected before it is sent leaves the old stop; a modify whose result cannot be read is outcome unknown, leaves the old stop resting, and does not place another stop until a later readable order book shows whether it landed; a new stop is placed only when the old one is already gone, so a rejected or unreadable place leaves the position without an exchange-side stop until a later cycle places one. A resting stop writes the new OID and trigger; a fill at submit books `post_tp_stop_loss_immediate` for the placed size and counts in the cycle trade tally (a capped-size residue keeps OID 0 and trigger 0); an unreadable fresh placement keeps the requested trigger with OID 0 and sends one CRITICAL alert; an unreadable order book, an external fill, a script error with no cancel or a failed subprocess writes nothing. `SLAdjustedTiersProcessed` and the `trail_from_here` owner advance only on a rested or filled stop, so the tier rule runs again once a stop OID exists. The rule trigger is clamped inside liquidation with `hlLiquidationPxForSide` + `clampStopInsideLiquidation` before the call; a clamped move sends the one `notifyHLStopPastLiquidation` alert of that cycle, with the walker's action mapping. The venue calls hold `lockHyperliquidTrailingUpdate(symbol)`, released before `mu`; every alert is sent outside both locks. |
| `leverage` | perps | Exchange margin and risk leverage, and the HL `update_leverage` call. Default 1×. Applied from flat. |
| `sizing_leverage` | perps | Notional multiplier (`cash × sizing_leverage`); defaults to `leverage`. |
| `margin_per_trade_usd` | perps, opt-in | `notional = min(margin_per_trade_usd, cash) × leverage`. Overrides `sizing_leverage`. In shared-wallet pool mode (2+ live HL/OKX perps where every member omits the capital fields) notional is `min(cap, account equity − deployed wallet margin) × leverage` with entry/mark reservation; a missing balance blocks opens but not closes. Allocated↔pool is flat-only and restart-required. |
| `risk_per_trade_pct` | HL perps, opt-in | `qty = (cash × pct/100) / stop_distance`, capped at `cash × leverage`. Bounds `(0, 10]`. Mutually exclusive with `sizing_leverage`, `margin_per_trade_usd`, `allow_scale_in`. Needs a stop owner resolvable at sizing time; regime-resolved and unified-close owners are rejected at load. **Fail-closed** — an unresolvable stop distance refuses the open rather than falling back to notional sizing. A risk↔notional mode switch is blocked while open. |
| `margin_mode` | HL perps | `isolated` (default) or `cross`. Applied from flat. |
| `allow_scale_in`, `scale_in` | HL perps, manual, opt-in | See Scale-in / pyramiding |
| `hedge` | HL perps, opt-in | `{enabled, symbol, side:"inverse", ratio, margin_mode, leverage}`. Auto-manages a leg on a different coin, mirrored from the primary's quantity by one per-cycle reconciler; the hedge leg has no independent stop, take-profit or close evaluator, and mark drift never re-trades. The hedge coin must be nobody's primary and no other strategy's hedge coin. Hedge PnL is recorded separately and excluded from the primary's lifetime trade and win/loss counts and from its loss streak. **Fail-closed** — a hedge failure on a cycle that added primary exposure unwinds that increment and sends a CRITICAL DM. The unwind and every hedge-leg reduce or close are sized closes planned by `planHLCloseOrder` against a fresh account read (the unwind with the primary's full book, the increment, its side and the peers' books captured by the scheduler; a hedge leg with its own side and no peers) and sent through `close_hyperliquid_position.py --close-mode`. An unwind cancels the primary's protection only on a full, uncapped unwind whose confirmed fill covers it. A skipped, deferred, rejected, unknown, capped or partial unwind books only the confirmed fill and sends a CRITICAL DM with the quantity still open. A hedge-leg plan skipped on a flat chain clears the leg; one skipped on a smaller or opposite-side chain sends nothing and leaves the book to the reconciler. Hot-reloadable only while flat; the backtester rejects an enabled block. |
| `hurst_gate` | opt-in | `{enabled, mode:"gate"\|"size", min, max, disarm_min, disarm_max, window_key, on_failure, size_floor}`. Sits ON TOP of `allowed_regimes`, which is unchanged. `mode=gate` holds position-increasing signals while disarmed; `mode=size` scales computed open size by `clamp(\|H-0.5\|/0.15, size_floor, 1.0)`, never above 1. Reads the Hurst metric from a composite regime window only — an ADX or missing window is rejected at load. `on_failure` inherits `regime.hurst_gate_on_failure` then `"open"`; fail-closed is flat-only. Hysteresis is keyed by a threshold hash, so editing a threshold resets it. Hot-reloadable always. **No thresholds ship** — calibration was inconclusive. Backtest through `--config`. |
| `allowed_regimes` | not options | Labels that allow an entry. Empty allows all. Needs `regime.enabled`. |
| `regime_gate_on_failure` | not options | `"open"` (default, legacy fail-open) or `"closed"`, which holds fresh opens only — management and closes always pass — while the regime store cannot produce a label. Overrides the global; empty inherits. Hot-reloadable always. `closed` with `allowed_regimes` and `regime.enabled=false` is rejected at load as a permanent block. |
| `regime_gate_window`, `regime_atr_window`, `regime_directional_window` | not options | Route the entry gate, regime-aware ATR and take-profits, and the directional policy to different horizons. Need a non-empty `regime.windows`; empty or `default` uses `regime.period`. Stamped labels persist on the position. Reload only while flat. |
| `regime_directional_policy` | HL perps | Per-regime `direction` plus `invert_signal` override. Needs `regime.enabled` and every canonical label. Resolves from the current regime while flat, from the position's frozen label while open. **Evidence-gated, DEFAULT-OFF** — it resolves to the base direction unless the `(asset, timeframe, classifier)` cell is certified in the shipped-empty artifact, so configuring it today is inert and logs a non-breaking warning. Certification is exact-match: a bare label never certifies its substates. Backtestable through `--config`. |
| `regime_window_divergence` | HL perps live | `{"short_window", "medium_window", "on_divergence": "trust_short"\|"trust_medium"\|"alert_only"}`. Overrides the direction when the two windows diverge (hard = bullish plus bearish, soft = one ranging), applied after the directional policy. Needs both windows in `regime.windows`. Visible in `/status`, DMs and a dashboard badge. The backtester rejects it. |
| `regime_profile_allocation` | HL perps | Two open-param profiles of one strategy; a slow long-window label picks the active one, switched hysteretically (`confirm_bars`, warn below 12) and only while flat — it freezes at open. `{window, profiles{label→name, all labels}, param_sets{name→overrides, exactly 2}, confirm_bars≥1, initial_profile}`. Needs `regime.enabled`. Persisted. Backtestable through `--config`. |
| `atr_method` | not options | Per-strategy override of the global; empty inherits. |
| `replay_sharing` | HL perps | `"none"` (default) or `"live_mirror"`. On live it records exposure-changing decisions to `replay_log_path`; on paper it suppresses its own position-increasing signals and replays the rows of the live source it names — the **same strategy `id`** by default, or the id in `replay_source_id` — opens at live quantity and VWAP, full closes at the paper mark under reason `replay_live_mirror`. Never forwarded to the check scripts. Hot-reloadable flat-only. Book-drift skips warn and DM (throttled) but are never retried; a close-while-flat is INFO only. |
| `replay_source_id` | HL perps, paper, opt-in | The id of the live strategy this paper mirror replays. Empty (default) keeps the pre-#1510 rule: the mirror reads the rows of the live twin that shares its own id, in another process. Set it when the live source and the paper mirror run in one process, where the two ids must differ. Requires `replay_sharing="live_mirror"` on both sides; the named strategy must be a live HL perps strategy in the same config on the same symbol and timeframe, and one live source takes one mirror. The daemon evaluates the source before the mirror in the same cycle, so a live decision reaches the paper book the cycle it is made. Changing it resets the mirror watermark and logs a WARN. Hot-reloadable flat-only. |
| `notify_ratchet_triggers` | HL perps, manual | Overrides the global; nil inherits. Notification-only, so it hot-reloads even while open. |
| `llm_entry_analysis` | all, opt-in | `{enabled, model, max_debate_rounds, timeout_s, notify_dm, notify_channel}`, default off; model default `claude-opus-5-5` (must accept `output_config.effort` and structured outputs; Claude Haiku 4.5 rejects effort), 1 round (0–3), 120s timeout (max 600), `notify_dm` on, `notify_channel` off, both-off legal. After a FRESH open — never an add, flip or manual — an async pipeline posts a short digest to the trade-alert DM and stamps `bullish`/`bearish`/`mixed` into `trade_diagnostics.llm_verdict` at close. Every call runs at effort `low`; the judge reply is schema-constrained JSON. **Advisory only**: an error, timeout, refusal, cut-off reply or off-schema verdict posts nothing, stamps nothing and has zero trade impact. Each job logs one line with its token usage (§ LLM review). Runs on its own job lane, never the shared Python semaphore. Needs `ANTHROPIC_API_KEY`. Hot-reloadable even while open. |
| `theta_harvest.*` | options | Early exit |
| `close_strategy.params.tp_tiers` with ref `tiered_tp_atr` / `tiered_tp_atr_live` | HL perps, manual | On-chain take-profit tiers, a list of `{atr_multiple, close_fraction}` (cumulative). Default `[{1.5×,0.4},{3×,0.8},{5×,1.0}]`; the final tier is coerced to 1.0 and a non-numeric tier is rejected. **Live:** the on-chain tiers own the take-profit, so an on-chain limit fill cannot race the in-process evaluator. Go keeps `closes` and always sends the position context, and adds `"close_owner": "on_chain_tp"` to `--strategy-refs` (batch: the slot's `strategy_refs`). Python then evaluates no close and applies no open-as-close fallback, so an opposite open signal holds while a position is open, and it echoes `close_owner` in its result. A same-direction open signal also holds while a position is open, so a live `type: perps` strategy with no `open_strategy` takes no `allow_scale_in` adds from signals, the same as paper. `manual-add` accepts only `type: manual`, so a live `type: perps` tiered strategy has no add path while a position is open. A `type: manual` tiered strategy adds with `manual-add`. Go treats a missing echo as a script error and holds the signal. For `type: perps`, that cycle skips every step after the check for the strategy: the ratchet, the live trailing stop replace, the fixed ATR stop arm, the protection sync, the post-take-profit stop adjustment, the hedge sync, the missing-entry-ATR alerts, and the regime and profile state updates. For `type: manual`, only the close evaluation and the regime stamp stop; the protection sync (it runs before the check), the post-take-profit stop adjustment, the ratchet and the trailing stop replace still run. A degraded feed result is exempt from the echo check and skips none of these steps. Resting on-chain orders stay for both types. Fallback: the in-process evaluator owns the take-profit (no marker, one alert per position) only when a position is open, no tier rests or is armed on-chain, and the sync cannot place tiers (no entry ATR, or for a non-dynamic ref no ladder of two or more tiers for the position's regime). **Paper:** never suppressed; the evaluator always runs. **Resting-limit model:** `check_hyperliquid.py` stamps `tp_model: resting_limit` on the position context, so every tiered evaluator prices tiers the way the on-chain plan does: the risk anchor (`risk_anchor_price`, else `avg_cost`), the entry ATR, and the position label (`protectionATRRegimeLabel`). `atr_source: live` only lets the live ATR price a position that has no entry ATR (load logs one `[WARN]` when it is set explicitly). The ladder rule matches `finalizeProtectionTiers`: two or more tiers need strictly increasing cumulative fractions and the final one closes 100%; a single tier keeps its existing evaluator behavior (no on-chain tier rests for it). Paper books a tier fill at the tier price (`close_tier_fill_price`, quantity-weighted when several tiers cross in one cycle) with no modeled slippage; detection stays on the check candle close, so a wick between cycles is not modeled. Live ignores that price. The load rejects a Hyperliquid ladder (strategy `tp_tiers`, each regime label, unified blocks, and ladders injected from `user_defaults`) whose cumulative fractions do not strictly increase or whose tier-keyed multiples are out of order; other platforms keep their ladders. |
| ref `tiered_tp_atr_regime` / `tiered_tp_atr_live_regime` | HL perps | Per-regime tiers. On Hyperliquid the `_live_` variants use the position label first (the confirmed applied label for the dynamic variant) and read the market regime only when the position has none; other platforms re-resolve each tick. Backtestable. |
| ref `tiered_tp_atr_live_regime_dynamic` | HL perps, manual | Unified per-regime block only. Applies a new ATR-regime label after `regime_confirm_cycles` consecutive cycles (a whole number >= 1, default 2), then re-places the stop-loss and take-profit orders for that label. Hot-reload-gated while a position is open. The backtester rejects it. |
| `close_strategy.params.tp_tiers` with ref `trailing_tp_ratchet` / `trailing_tp_ratchet_regime` | HL perps, manual | Shape and rules in Configure. `use_defaults: true` or an omitted `tp_tiers` takes the system ladder (scalar: trails 1.5×/1.5×/0.8× at 2×/2.5×/3× ATR; regime: per quality group). Tier-table changes blocked while open. |

Discord and Telegram: `enabled`; `channels` (the platform/type map for summaries and, as a fallback, trade alerts); `trade_alert_channels` (an override for fills only, same key scheme, hot-reloadable); `dm_channels`; `owner_id` (prefer `DISCORD_OWNER_ID`); `ephemeral_replies`; `report_repo` and `report_github_token`.

---

## Strategy Reference

**Never enumerate strategies from memory.** The registries are the source of truth and they change:

```bash
uv run --no-sync python shared_strategies/open/spot/strategies.py --list-json
uv run --no-sync python shared_strategies/open/futures/strategies.py --list-json
uv run --no-sync python shared_strategies/options/strategies.py --list-json
```

`/go-trader-closing-strategies` catalogs every registered close evaluator — name, description, platforms, config params — and marks the ones `user_defaults.close` overrides.

Strategies registered `edge_status="no_edge"` (#1681) are hidden from `--list-json` and `go-trader init` but stay registered, so an explicit `args[0]` or config ref still loads them.
The label is an availability policy: the cited review or study (`edge_source` `fee_audit_m5`, `study_fail`, `study_inconclusive` or `unvalidated`; `edge_ref` names the evidence) has not established approved edge evidence.
It does not claim the strategy lost money; each study keeps its own outcome.
Every effective no-edge open name or open-strategy close fallback is admitted by one rule in config validation, probe and every check script: exactly one explicit `--mode=paper` (either argument form) runs without an acknowledgement; `--mode=live` or a missing `--mode` needs `"allow_no_edge": true` on the strategy; an empty, unknown, dangling or repeated `--mode` and a non-boolean acknowledgement are refused.
The Go spot dispatch (type `spot`, not OKX or Robinhood) is paper-only and passes `--mode=paper` itself when args carry no mode.
Admission never changes portfolio scope or storage: `isLiveArgs` still decides those, so a missing-mode strategy acknowledged for admission stays in the paper partition.
A live no-edge strategy still warns at startup (stderr and one owner DM) and on reload for each new warning identity, and the summary and `inspect` show `edge=no_edge:<source>` with `(ack)` or `(paper)`.

What an operator needs to choose one:

- **Direction.** A bidirectional strategy needs `"direction": "both"`, or `"short"` to run it as a dedicated bear-only instrument. Short-only strategies emit sell signals exclusively and are pre-registered bidirectional, so they need one of those two values. Pair a short strategy with `allowed_regimes: ["trending_down"]` for clean entry gating.
- **Validation status.** `--list-json` returns only the ID and the description, and the description carries the research verdict — read it. The availability tag surfaces as `edge=no_edge:<source>` in the startup summary and in `./go-trader inspect` (with the evidence reference), plus a one-time owner DM for a live strategy. Treat anything tagged `no_edge`, or described as not out-of-sample validated, as paper-trade-first.
- **Entry versus exit.** Several entry strategies ship entries only and expect the exit to come from config — pair them with a close evaluator and a stop rather than expecting a built-in exit.

Platform conventions:

| Platform | ID prefix | Type / script |
| --- | --- | --- |
| BinanceUS spot | none | `spot`, `shared_scripts/check_strategy.py` |
| Hyperliquid perps | `hl-` | `perps`, `shared_scripts/check_hyperliquid.py` |
| Hyperliquid manual | `hl-` | `manual`, no script or interval; driven by the `manual-*` CLI; may share a coin with HL perps peers |
| TopStep futures | `ts-` | `futures`, `shared_scripts/check_topstep.py` |
| Robinhood | `rh-` | spot through `check_robinhood.py`, options through `check_options.py --platform=robinhood` |
| OKX | `okx-` | `check_okx.py` (spot and perps), `check_options.py --platform=okx` for options |
| Deribit options | `deribit-` | `check_options.py --platform=deribit` |
| IBKR options | `ibkr-` | `check_options.py --platform=ibkr` |
| Luno | `luno-` | Luno adapter and scripts |

Options on every platform run as paper only (#1823): no venue order path exists for options, so `validateConfig` refuses a `type: options` strategy whose args `isLiveArgs` classifies as live, and `check_options.py` refuses the same forms with a JSON `error` and exit 1.
The `rh-ccall-spy` example below carries no `--mode`, so it runs as paper.

ID conventions: `ts-{strategy}-{symbol}`, `rh-{strategy_short}-{asset_or_symbol}`, `okx-{strategy_short}-{asset}` for spot and options, `okx-{strategy_short}-{asset}-perp` for perps. Options short names: `vol_mean_reversion → vol`, `momentum_options → momentum`, `protective_puts → puts`, `covered_calls → calls`, `wheel`, `butterfly`.

Example entries:

```json
{"id":"momentum-btc","type":"spot","script":"shared_scripts/check_strategy.py","args":["momentum","BTC/USDT","1h"],"capital":1000,"max_drawdown_pct":60,"interval_seconds":300}
{"id":"ts-momentum-es","type":"futures","platform":"topstep","script":"shared_scripts/check_topstep.py","args":["momentum","ES","1h","--mode=paper"],"capital":1000,"max_drawdown_pct":5,"interval_seconds":3600}
{"id":"rh-ccall-spy","type":"options","platform":"robinhood","script":"shared_scripts/check_options.py","args":["covered_calls","SPY","--platform=robinhood"],"capital":5000,"max_drawdown_pct":10,"interval_seconds":14400,"theta_harvest":{"enabled":true,"profit_target_pct":60,"stop_loss_pct":200,"min_dte_close":3}}
{"id":"okx-sma-btc-perp","type":"perps","platform":"okx","script":"shared_scripts/check_okx.py","args":["sma_crossover","BTC","1h","--mode=paper","--inst-type=swap"],"capital":1000,"max_drawdown_pct":5,"interval_seconds":3600}
```

**Peers on one Hyperliquid coin** share a single on-chain position. They must agree on `margin_mode` and exchange `leverage`; `sizing_leverage` may differ. Each peer places its own per-strategy-sized reduce-only protection, so several peers may own fixed-ATR, margin, or trailing stops at once. Peers that omit every stop field fall back to `default_stop_loss_atr_mult`. A per-strategy circuit-breaker drain skips the on-chain close when peers share the coin, so the exchange leg stays open until another path flattens it. Sub-account isolation is the only route to full per-strategy independence.

---

## Add Or Change Strategies

Open registry: `shared_strategies/open/registry.py`. Close registry: `shared_strategies/close/registry.py`.

New spot or futures strategy:

1. Add the implementation and its `@register(...)` in `shared_strategies/open/registry.py`. Declare the required `short_entries=True|False`: True only when the strategy ships short entries (a -1 that opens a short, not a long exit). A missing or non-bool value fails at import. True needs `"futures"` in `platforms`, and any shipped `allow_short: True` (base or variant) needs True. `short_entry_strategies()` derives the fee-audit short set (`LIVE_BIDIRECTIONAL_STRATEGIES`).
2. Set `platforms=(…)` correctly; use variants for platform-specific defaults.
3. Append the name to `PLATFORM_ORDER`.
4. Add the short name to `knownShortNames`, the `registeredOpenStrategyPlatforms` row and default entries in `scheduler/init.go`; with `short_entries=True`, also add it to `bidirectionalPerpsStrategies` (wizard and Discord add write `direction: both` only for listed names).
5. Add a param grid to `DEFAULT_PARAM_RANGES` in `backtest/optimizer.py`.
6. A parameter rule the binary `constraints` cannot express (integer-only, finite, type checks) goes in an optional `param_validator=` callback on `@register`: `validate_params` runs it on the effective parameters, and `check_hyperliquid.py` runs it for every admitted open and open-fallback close reference before any candle fetch (solo) or shared-state build (batch), failing only that slot.
7. A strategy without approved edge evidence registers `edge_status="no_edge"` with a valid `edge_source` and a nonempty `edge_ref` (the evidence file); mirror it in `noEdgeStrategies` in `scheduler/edge_status.go`. That hides it from discovery, runs it with explicit `--mode=paper`, and needs `allow_no_edge: true` for live use. A new study records its verdict without changing that rule. Never add the name to `v20StampEligibleStrategies` in `scheduler/config_migration_v20.go`: that frozen list holds only the no-edge names that could run live before v20, so the legacy v20 migration never stamps `allow_no_edge: true` on a later name.
   A fixed candle-cadence parameter (`expected_interval_ms`) also needs its registry default in `fixedIntervalStrategyDefaults` (`scheduler/fixed_interval_strategies.go`): config load refuses an open or close-fallback reference whose explicit or default value differs from the `args` timeframe (a Hyperliquid manual strategy without `args` uses `timeframe`), or that runs outside perps and Hyperliquid manual strategies, and the backtest harnesses fill it from `--timeframe` (`bind_fixed_interval_params` in `shared_tools/closed_bar.py`) and refuse an explicit mismatch.
   A research candidate that must never run in production also registers `production_refused=True` (requires `no_edge`) and is mirrored in `productionRefusedStrategies`; every mode, including explicit paper and `allow_no_edge: true`, is then refused, and its admission tests assert that refusal instead of a paper evaluation. `cumulative_volume_delta_divergence` (#1638), `bitcoin_lead_lag` (#1640), `macro_event_breakout` (#1654), `volume_profile_reclaim` (#1665), `perpetual_premium_reversion` (#1639), `order_book_imbalance` (#1635), the quote capability `inventory_market_making` (#1861) and the pair policy `cross_exchange_perp_arbitrage` (#1878) use it; a production-refused candidate gets no `knownShortNames` or `bidirectionalPerpsStrategies` entry before its promotion (the parity script permits that absence only for names refused in both languages).
   Admission tests for a new no-edge strategy: one missing-mode refusal (no `--mode`) that asserts the strategy name and `edge_status=no_edge`, plus one admitted `--mode=paper` evaluation (reuse a behavior test that already runs one; add one only when none does).
   A close-fallback reference is a separate path and keeps its own single refusal.
   The full ten-input refusal matrix runs only once, against `check_hyperliquid.py` in `shared_scripts/test_no_edge_admission.py`, beside one case per refusal class for each other check script; never copy either matrix per strategy (the Test budget step in Tests rejects a copy).
   Tests for distinct behavior stay: effective open and close-fallback routing, required admitted argument forms, acknowledgement, batch evidence, parameter preflight, signal values, backtester parity and closed-bar handling.
8. Run the registry, optimizer and `scripts/test_go_python_registry_parity.py` tests.

New basket evaluator (ranks a basket universe through the Basket Coordinator, never one symbol):

1. Add the implementation and its `@register(...)` in `shared_strategies/open/registry.py` with `basket=dict(BASKET_CONTRACT)`, `platforms=("futures",)` and `short_entries=False` (registration refuses `short_entries=True`; short targets come from negative weights), explicit `constraints`, a `param_validator` and, when the configuration can be invalid for the parameters, a pure `basket_preflight(effective_params, allocation_context)` callback that raises `ValueError` (registration refuses a non-callable callback and any callback on a single-symbol entry).
   The entry function is `fn(frames, eligibility, allocation_context, **params)`; it returns `{"scores", "weights", "exclusions"}` over exactly the eligible members, or `{"hold": "<reason>"}` alone.
   It reads no configuration, clock or trading state; budgets and history limits come only from the read-only `allocation_context`, never from `params`.
2. Add no `PLATFORM_ORDER`, `bidirectionalPerpsStrategies`, `DEFAULT_PARAM_RANGES` or wizard entry; `build_registry` drops basket entries, and single-asset backtests and `parity_diff` refuse them. Never edit the shims.
3. Mirror it in Go: `basketRankerStrategies` and `basketRankerParamKeys` (parameter keys only, never allocation-context fields) in `scheduler/basket_config.go`, `knownShortNames` and `registeredOpenStrategyPlatforms` in `scheduler/init.go`, and `noEdgeStrategies` in `scheduler/edge_status.go` (`edge_ref` must name a file that exists in the same change).
4. Update `scripts/test_go_python_registry_parity.py` (basket ranker set, no-edge total), add the name to the forward-peek `SKIP_STRATEGIES` with its basket-only ground, and prove post-deadline invariance through the real runner in a `TestBasketRealRanker*` Go case under the `pyintegration` tag (run this prefix locally).

New quote capability (decides two-sided quotes at due deadlines through the Quote Lifecycle coordinator, never a scalar signal; #1861):

1. Put the versioned parameter specification (units, defaults, bounds, integer and cross-field rules) once in Go as a raw-string constant (`quotePolicySpecJSON` in `scheduler/quote_policy_spec.go`) and once as a self-contained Python literal beside the registration (`shared_strategies/open/inventory_market_making.py`); the registry never reads a Go file at import, because the container image ships no Go source.
   `scripts/test_go_python_registry_parity.py` pins the two copies equal and their canonical SHA-256 equal (`quote-replay discover` prints the binary's copy).
2. Register it with `quote=dict(QUOTE_CONTRACT)`, `quote_spec=`, `platforms=("futures",)`, `short_entries=False` (signed inventory belongs to the quote contract), a `param_validator`, `edge_status="no_edge"` and `production_refused=True`; registration refuses any other combination and any basket metadata.
   The registered function is a scalar refusal, so composer, check scripts, candle backtests, `walk_forward_optimize`, `parity_diff` and close fallback refuse it (`refuse_quote_capability` in `backtest/registry_loader.py`, the quote guard in `admit_strategy_references`).
3. Append it to `PLATFORM_ORDER["futures"]` so the hidden futures lookup resolves its production refusal, and give it an empty `DEFAULT_PARAM_RANGES` row (no optimizer admission); add no `knownShortNames`, `bidirectionalPerpsStrategies`, `v20StampEligibleStrategies` or wizard entry, and never edit the shims.
4. Mirror it in Go: the quoted key in the `quoteCapabilityStrategies` map literal (version and validator; no `init()`), `productionRefusedStrategies`, `noEdgeStrategies` and `registeredOpenStrategyPlatforms`; register its book need through `feedBookStrategyNeeds` (`inventory_market_making_feed.go`).
5. Add it to every exact production-refused table (the parity literal, `TestBitcoinLeadLagRegistrationTables`, `test_cumulative_volume_delta_divergence_integration.py`), an admission fixture in `TestCumulativeVolumeDeltaIsRefusedInEveryModeAndReference`, and the forward-peek `SKIP_STRATEGIES` with its ground.

New pair policy (research-only; decides one cross-venue pair through the Research pairs coordinator, never one symbol; #1875, canonical registration #1878):

1. Add the evaluator as `shared_strategies/open/<policy>.py` with `POLICY`, `PARAM_KEYS` and `evaluate_pair(request: dict) -> dict`, standard library plus `shared_tools/pair_evidence.py` and `shared_tools/pair_inputs.py` only, loaded by file path; add no `@register` decorator, shim, discovery, `PLATFORM_ORDER`, `DEFAULT_PARAM_RANGES`, `knownShortNames`, `bidirectionalPerpsStrategies`, `v20StampEligibleStrategies` or wizard entry.
2. Add one descriptor to `_PAIR_DESCRIPTORS` in `shared_strategies/open/pair_registry.py` (standard library only): evaluator module name `go_trader_pair_policy_<name>`, repository-relative `path`, `param_keys` equal to the evaluator's `PARAM_KEYS`, `description`, `platforms=("futures",)`, `short_entries=False`, `edge_status="no_edge"`, `edge_source`, `edge_ref` (a file that exists in the same change) and `production_refused=True`.
   `registry.py` projects every descriptor into `STRATEGIES` through `register(pair=PAIR_CONTRACT)` before `DISCOVERY_HIDDEN_STRATEGIES` is computed; registration refuses a name without a descriptor, any field that differs from it, basket or quote metadata, default parameters, constraints or a `param_validator`, and `build_registry` drops pair entries for visible and hidden registries.
3. Mirror it in Go: `pairPolicyStrategies`, `pairPolicyParamKeys` and a raw validator in `pairPolicyParamValidators` (`scheduler/pair_config.go`), plus quoted keys in `productionRefusedStrategies`, `noEdgeStrategies` and `registeredOpenStrategyPlatforms` (`openStrategyRegisteredFor` excludes pair policies).
4. Add it to every exact production-refused table (the parity literal and no-edge total, `TestBitcoinLeadLagRegistrationTables`, `test_cumulative_volume_delta_divergence_integration.py`), an admission fixture in `TestCumulativeVolumeDeltaIsRefusedInEveryModeAndReference`, the forward-peek `SKIP_STRATEGIES` with its ground and a `docs/backtesting-registry.md` row for its candidate directory.
5. Prove it through the real process in a `TestBasketPairPolicyReal*` case (`pyintegration`, run locally); `scripts/test_go_python_registry_parity.py` pins the Go names and parameter keys, the descriptors, `STRATEGIES` and the evaluator's `POLICY` and `PARAM_KEYS` equal.

For a close evaluator, add an `evaluate(position, market, params)` implementation under `shared_strategies/close/` and register it in `close/registry.py`.

Do not edit `shared_strategies/open/{spot,futures}/strategies.py` — they are thin shims.

Before refactoring a registry or shim, snapshot discovery and diff it afterwards unless the change is meant to alter discovery:

```bash
uv run --no-sync python shared_strategies/open/spot/strategies.py --list-json > /tmp/spot.json
uv run --no-sync python shared_strategies/open/futures/strategies.py --list-json > /tmp/futures.json
```

---

## Custom Platform Integration

Gather first: platform name and ID prefix; products (spot, perps, futures, options); API docs URL or `ccxt` coverage; credential environment variable names; fees; assets and strategies; paper and live requirements.

1. `platforms/<name>/__init__.py`
2. `platforms/<name>/adapter.py` — exactly one class whose name ends in `ExchangeAdapter`; check scripts load the file through `importlib` and pick the class by `endswith("ExchangeAdapter")`
3. Implement public adapter methods only; check scripts must never touch private attributes
4. `shared_scripts/check_<name>.py`, only if an existing entry script does not fit. Go parses stdout as exactly one JSON document, so a venue SDK that prints must run inside an adapter guard that sends its output to stderr (Robinhood: `robinhood_library()` in `platforms/robinhood/adapter.py`), and an entry script that can print other library text writes its one JSON result through `json_stdout()` from `shared_tools/stdout_contract.py` (Robinhood: `check_robinhood.py`, `close_robinhood_position.py`, `fetch_robinhood_positions.py`, `check_balance.py`; `check_options.py` and `fetch_candles.py` rely on the adapter guard alone).
   Robinhood live orders go only through the adapter-owned `place_market_order` and `resolve_order` (`platforms/robinhood/adapter.py`): every request has an explicit timeout inside the `--budget-ms` budget, login runs under a watchdog, and the result carries one `order_outcome` (`filled`, `partially_filled`, `no_fill`, `rejected`, `not_sent`, `not_found`, `open`, `unknown`, `inconsistent`; the close script adds `already_flat`).
   The scripts exit 0 only for a confirmed fill or `already_flat`, and Go books only a confirmed fill (#1815)
   A signal sell passes `sell_policy` to `place_market_order`: the adapter reads the pair rules and `quantity_available`, sizes the order with `plan_sized_sell` (exact `Decimal`, floor to the increment, sole-owner whole-close dust sweep) and echoes `sizing`; every refusal happens before the send (#1819). The emergency close script uses the same path with close kind `emergency` and the go-trader book as the ask, which never sweeps (#1825).
   Every Robinhood login goes through `robinhood_login()` in `platforms/robinhood/adapter.py`, which accepts a login only with a returned token, the library login state set, and the matching `Authorization` header, and which passes robin_stocks login text through an allowlist so no credential or token reaches stderr.
   `robinhood_login()` also owns the session (#1818): it checks the `GO_TRADER_ROBINHOOD_SESSION_DIR` folder before any request, takes the per-account lock under one watchdog, gives robin_stocks an explicit stage `pickle_path`, promotes the stage only after a validated login, reads the stored session with a global-refusing unpickler, keeps a stable per-account device token, and refuses a device verification prompt with a latch that only `go-trader robinhood-login` clears.
5. ID-prefix inference in `scheduler/config.go`
6. Fee dispatch in `scheduler/fees.go`
7. Executor wiring, only if a new live execution path is needed
8. Config examples
9. The init wizard and `generateConfig`, if the platform is user-selectable
10. `pyproject.toml` for any new SDK dependency, then `uv sync`
11. Egress proxy mode: build every client through `shared_tools/egress_policy.py` (`pin_ccxt`, `pin_requests_session`, `requests_proxies`, `pin_yfinance`, `pin_hyperliquid_sdk`), add the family to `--check-policy` in `shared_tools/egress_bootstrap.py`, and use `egressHTTPClient` / `egressWebsocketDialer` for a Go client (§ Egress Proxy Mode)
12. Tests only where CLAUDE.md § Testing allows them (venue states, money math such as fees, paper/live parity); otherwise prove the platform with the real build and `--once` run below

A check script that receives `--htf-filter` must return the `htf_filter` block from `shared_tools/htf_filter.py` (`run_htf_filter`, `htf_skipped_block`, `htf_not_evaluated_block`); with the filter on, Go treats a result without it as a script error (§ Higher-timeframe filter).

Options platforms also need volatility, expiry, strike, and premium helpers, `CalculateOptionFee`, and an `OptionPlatforms` entry. Reference adapters: spot `binanceus`, perps `hyperliquid`, futures `topstep`, options `deribit`.

```bash
uv run --no-sync python -m py_compile platforms/<name>/adapter.py
uv run --no-sync python -m py_compile shared_scripts/check_<name>.py
/opt/homebrew/bin/go -C scheduler build .
./go-trader --config scheduler/config.json --once
```

---

## Storage Ownership

Set `paper_db_file` and the scheduler runs both modes in one process against two state files. `db_file` is the **primary** file; `paper_db_file` is the **paper** file. Omit `paper_db_file` and every behaviour below collapses to the single-file layout.

**Several paper sources.** Root `paper_sources` folds more than one paper deployment into the same process, each with its own file. Every entry carries `id` (the stable identity: `[a-z0-9][a-z0-9_-]{0,31}`, never `live`, `paper` or `primary`), `db_file` and an optional `label` and `portfolio_risk` override. A paper strategy joins a source with `paper_source: "<id>"`; a live strategy that carries one is refused at load, as is a `paper_source` naming no declared entry, a duplicate id and a source `portfolio_risk.paper` (a source override cannot nest another override).

The identity that matters at runtime is the **risk partition**: `live`, `paper` (the default paper partition) or `paper:<id>`. A partition is one independent risk, evaluation and storage domain — its own peak, drawdown, latch, daily-loss ledger, notional total, exposure total, correlation model and state file. `paper_sources[].db_file` must be pairwise distinct and distinct from `db_file` and `paper_db_file`; an alias exits 80. One partition owns exactly one file, and the lock and save order is primary, then paper, then the sources by id.

`paper_sources` entries, their `db_file`, and any strategy's `paper_source` are **restart-required**: a SIGHUP that adds, removes or repoints one is rejected. So is a source's `portfolio_risk.max_notional_usd`, exactly as the root and `paper` ones are.

**Who owns what**

| Table | Owner | Notes |
| --- | --- | --- |
| `app_state` | primary | The paper file's row is read once at boot and its summary/leaderboard stamps are unioned in with primary precedence; it is never written again. |
| `strategies`, `positions`, `option_positions`, `trades`, `closed_positions`, `closed_option_positions`, `trade_diagnostics`, `pending_manual_actions` | the strategy's scope | Identifiers are translated in both directions at the SQL boundary. `strategies.paper_funding_state` (the paper funding timeline, no strategy ids inside) commits with cash and trade rows in the owning file; without `paper_db_file` default paper strategies share the primary file under paper scope ownership. |
| `portfolio_risk`, `kill_switch_events`, `correlation_snapshot` | the row's explicit scope | A legacy unscoped row is placed using the owning file's validated scope. |
| `wallet_ledger_state`, `wallet_transfers`, `cashflow_journal`, `cashflow_journal_state`, `cashflow_hl_basis_state`, `cashflow_hl_basis_book`, `cashflow_hl_basis_seen`, `pending_limit_orders` (manual rows, `owner_kind = 'manual'`) | primary, live-only | A write naming a paper-scope strategy is refused. Every legacy pending-limit read, insert, row-id update, delete, cancel flag, operator marker and tally filters `owner_kind = 'manual'`, so it can never select a quote row in the same file. An unmigrated read-only file projects every row as manual. |
| `pending_limit_orders` (quote rows, `owner_kind = 'quote'`) | the quote owner's partition | Quote order lifecycle (#1860): explicit `partition_scope`/`partition_source`, `quote_role` (`bid`, `ask`, `stop`, `close`), `quote_generation` (the generation for bid/ask, the per-role sequence for stop/close), provider, account key, venue namespace, the frozen canonical `request_json`, `request_expiry_ms`, client and venue identities, `submit_state`, `cancel_state` and exact `confirmed_qty`/`applied_qty`. Unique per (provider, account, client id) and per (partition, strategy, role, generation). Triggers refuse an invalid quote identity, any change to a quote row's identity or frozen request, and every delete. A quote row is addressed only with its file, partition and strategy; a numeric id alone selects nothing. |
| `quote_contracts` | the quote owner's partition | One row per quote owner: coin, provider, account key and namespace (unique together, so ownership is exclusive), both fingerprints, the start time, the monotonic book `revision`, the last committed deadline, the applied coverage boundary, the generation/stop/close sequences, the exact book (`book_json`: signed quantity, cost basis, cash, realized gross, fees, funding, position id, first opening fill time, risk anchor) and the durable operator hold. Every full, partition, subset and single-strategy save validates each durable owner first: an absent or stale revision, an omitted owner in a full save, and a changed cash, position or `paper_funding_state` all refuse before any row is replaced. |
| `quote_generations` | the quote owner's partition | One row per generation and deadline (unique per owner and deadline): desired sides, the reserved risk envelope inputs, status (`reserved`, `active`, `resolved`) and the terminal proof. |
| `quote_fills` | the quote owner's partition | Immutable raw fill evidence keyed by (provider, account, namespace, coin, order, event time, trade id, kind) with the canonical body and digest, so an exact repeat and a conflicting copy stay distinguishable. `kind = 'fee_correction'` is a separately keyed authoritative fee adjustment. `settlement_id` is set once by the settlement that applies it; triggers refuse any other update and every delete, so unapplied and completed evidence survive strategy and position replacement. |
| `quote_settlements` | the quote owner's partition | One immutable row per committed settlement: new revision, kind (`fills`, `funding`, `both`), deadline, applied coverage boundary, applied fill ids and funding ids, and the commit summary. |
| `quote_funding_evidence` | the quote owner's partition | Live funding events for a quote-owned coin, saved by `ingestFundingEvent` instead of a trade row, keyed by (provider, account, namespace, coin, funding dedup id) with the raw body, a digest and the applying settlement. Retained and immutable like `quote_fills`. |
| `regime_window_history`, `regime_window_transitions`, `regime_reversal_alerts` | primary for new writes | A paper file's pre-split history is read with its source named and is never pruned or marked. |
| `entry_decision_state` | the strategy's partition | Closed-bar entry allowance (#1769): per `(scope, source, stored strategy id, symbol, interval_ms, target_side)` the newest observed decision candle, the upgrade bootstrap boundary and the consumed boundary. Boundaries only move forward (`MAX` upsert), the row commits with the book in the owning file, and no foreign key ties it to `strategies` or `positions`, so a whole-book save, a partition save or a deleted position never removes it. |
| `basket_contracts`, `basket_rebalances`, `basket_member_actions`, `basket_member_state` | the basket's partition | Basket coordinator records (#1781), all in the one file that owns every member. `basket_contracts` holds the last committed contract per basket. `basket_rebalances` is keyed by partition, basket id and deadline; its targets, input fingerprint and frozen eligibility evidence are written once. `basket_member_actions` holds one row per elective member action with a unique action identity (rebalance, member, direction, sequence), an account-bound client order id (unique per provider and account), the pre-order exposure and protection snapshot, venue evidence and cumulative confirmed and applied fills; a partial unique index allows one unresolved action per basket. `basket_member_state` holds the quantity the basket last committed per member and, when `holding_horizon_seconds` is set, the member's holding-horizon lifecycle (#1794): the booked position id, the first confirmed entry time, the expiry time, the expiry progress, the durable attempt sequence, the retry time, the retained alert keys and a separate reopen-suppression deadline that outlives the lifecycle. Expiry actions are `basket_member_actions` rows with `purpose = 'expiry'`, the lifecycle position id, the expiry time and the lifecycle attempt; they keep the inline unique key, associate with the latest saved rebalance deadline (or 0), and take `seq` from every saved row for that key inside the reservation transaction. Reservations and venue outcomes commit on their own; a committed fill, its book change, the consumed decision, the lifecycle transition and the basket progress commit only in a save that writes that member's book. A full or partition save updates every member; a subset save and the single-strategy book save (with its manual-action acknowledgement or queued row) update the lifecycle and committed actions only for the members whose books they write; a book saved without its runtime pointer still reconciles its stored lifecycle row against the saved contract, but it only keeps or clears that row and never starts a lifecycle, so a strategy kept after its basket is removed cannot create removed-basket recovery evidence; a progress-only write never changes a book-dependent field. Columns are added by an idempotent step, and read-only inspection projects defaults for an unmigrated file. Completed rows are kept. `storage-inspect` reports pending basket recovery and unresolved holding-horizon expiry and refuses rows in a file that does not own their partition or member; an active lifecycle counts as recovery evidence for the wrong-file, unmapped-member and removed-basket refusals even at zero expected quantity. **Research pairs (#1862)** share these four tables in pair mode. `basket_contracts` holds the pair contract (`mode: "pair"`, `pair_v` 2 since #1869 (a saved `pair_v` 1 contract is refused, never converted), the `pair` body; a basket contract omits all three, so version-1 basket bytes and fingerprints are unchanged) plus `progress_json`/`progress_seq` (the pair lifecycle, first-fill and recovery-start times, exceptions, alert keys, visible obligations, retry state, the last bound reading and the frozen member cost evidence `cost_evidence` (#1887), which no later write may add, remove or replace; the venue position barrier `position_match` (#1877: generation, status, reason, check time, episode and hold start; missing loads as unchecked, a saved match never authorizes an increase after a restart, and the flat reset keeps it); a write needs a sequence at least the saved one, so a stale runtime save fails its transaction). `basket_rebalances` rows are the pair cadence slots keyed by pair id and slot time (statuses in the Basket Coordinator section); since #1871 a frozen slot's `evidence_json` holds the canonical version-2 quote envelope (at most `envelope_max_bytes`, written once, verified before every use, never pruned from the file) and `input_fingerprint` its SHA-256; since #1875 its `targets_json` decision record (version 2 with `bound_evidence` since #1877; version-1 records keep their bytes and authorize no order) holds at most 98304 bytes (`pairTargetsMaxBytes`; a larger record is replaced by a hold carrying `{"oversize_bytes":N}`), so a research run grows by at most `envelope_max_bytes` plus 98304 bytes per cadence slot. `basket_member_actions` was rebuilt once (idempotent, all indexes recreated) with an immutable `group_mode` (`basket` for every existing row) and the pair fields: native instrument and quantity, contract size, the immutable `request_json`, the quote event, the saved price bound, the acceptance deadline, the submit time, cumulative confirmed quantity and notional, explicit fee evidence (presence, currency, signed amount, evidence id and version, never a `MAX` merge), applied notional and applied fee version, terminal and coverage evidence, retry state, recovery notes and the booked trade identities; `purpose` adds `pair_entry`, `pair_exit` (elective) and `pair_protective`, `pair_kill_switch`, `pair_operator` (protective). `idx_basket_actions_one_pending` now covers basket rows only and keeps one unresolved action per legacy basket; `idx_pair_actions_one_elective` allows one unresolved elective action per pair and `idx_pair_actions_one_protective` one unresolved protective reduction per partition, provider, account and native instrument; the reservation check applies the same rules, and a trigger refuses any change to the mode, purpose, client identity, account, instrument or pair request. Every pair write checks its changed-row count and fails the transaction when a guard misses. A progress-only write keeps applied totals, booked trade identities, quote consumption and expected quantity unchanged; a subset or single-book save advances them only for the members whose books it writes, and a completed slot is written as `executing` until a save holds every member book. Since #1874 (Research pairs, Pair settlement evidence) an idempotent step adds `accepted_at_ms` (the first authoritative acceptance time, then fixed), `fill_coverage_json` and `fee_coverage_json` (the accepted execution and fee-covered fill sets), `fee_final` (monotonic) and `settlement_json` (the conversion ledger), all defaulting to empty or 0; the coverage columns may be written by any pair write but only from the saved prior value (fee coverage only together with a higher fee version), finality can never be cleared, and `settlement_json` is written only by a save that writes the member book and only from its saved prior value. Read-only inspection, load and the replay reader project the defaults for an unmigrated file, and an empty value means unknown evidence. Since #1914 (Research pairs, Native emergency evidence and accounting) a one-time rebuild adds the terminal action status `held`, `idx_pair_actions_one_protective` counts native rows only while `reserved`, `submitting` or `unresolved`, and pair progress version 2 adds `native_receipts` (sealed unattached native fill receipts, removed only in the transaction that inserts their accounting action) and `paper_closes` (staged then committed own-mark paper emergency closes, at most 32 committed kept); both are recovery evidence for the wrong-file, removed-pair and contract-change refusals. |
| `basket_replay_runs`, `fill_cost_policies`, `basket_replay_venue_events`, `basket_replay_steps`; `trades.cost_policy_id` | the replay run's own file | Offline basket replay records (#1791), written only by `go-trader basket-replay` in a state directory it created. `basket_replay_runs` holds exactly one run record (owner marker, protocol, source, contract, config, input and run fingerprints, cost policy, cursor and steps applied); the cursor commits in the same transaction as every full or partition save, so a step is applied once its books are durable. `fill_cost_policies` holds the frozen fee, slippage, fill-role and spread policy of a non-default replay cost run, linked to each fill by `trades.cost_policy_id` (empty for every scheduler row; the column is added by an idempotent migration). `basket_replay_venue_events` is the scripted venue's own memory by client order id: each submit and query outcome commits on its own before the coordinator sees it, so a restart queries before any new submission; every query call is recorded (#1790), also an unresolved poll with no scripted result, a query of an order with no submit evidence and a query that returns an earlier terminal outcome, and those observations carry script index -1, never consume a scripted query outcome and are skipped when a later query or a restart reads the venue memory; the output lists the whole table in event order. `basket_replay_steps` holds each applied step's output record and lot refusal count, written after the durable save that covers the step, so a resumed run reports every step. `storage-inspect` lists replay runs and cost policies and refuses a file that holds a replay run, so the scheduler never starts on replay state. |
| `basket_replay_runs` version-2 columns (`schema_version`, `lifetime`, `member_costs_json`, `cursor_step_id`, `cursor_phase`, `cursor_binding_seq`, `pending_step_id`, `pending_step_ms`, `pending_prefix_hash`), `basket_replay_steps` version-2 columns (`step_id`, `at_ms`, `phase`, `status`, `result_hash`), `basket_replay_objects`, `basket_replay_failures` | the replay run's own file | Offline pair replay records (#1881), written only by a version-2 `go-trader basket-replay` run in the directory its research admission claimed. The columns are added by an idempotent migration that also runs for version 1 (defaults: schema 1, empty strings, zero), and read-only inspection and the version-1 readers keep their meaning; in version 2 `step_index` is the run's monotonic step ordinal and `record_json` holds the pending record (host state before the cycle, the saved collection, the coordinator checkpoint) and then the completed step record with its `result_hash`; the host state carries the optional `oracle_next_ms` (the next research oracle hour of a funding policy paper run, #1934, omitted when zero). The pending row and the saved collection commit on their own; the coordinator checkpoint and the completed record with the cursor advance commit inside full saves through the save hook, never in an internal coordinator save. `basket_replay_objects` keys each frozen input object by (run id, object hash) with its relative path, size, record count and manifest entry. `basket_replay_failures` holds process failure evidence and scenario markers by sequence, written immediately. `storage-inspect` keeps listing and refusing the run. |
| `basket_replay_inputs`, `basket_replay_calls`, `basket_replay_slots`; `basket_replay_runs.recording_cursor`, `basket_replay_runs.recording_logical_ms`; `basket_replay_failures.call_id` | the paper run's own file | Isolated paper recording records (#1882), written only by `go-trader basket-replay-paper` in the directory its research admission claimed. `basket_replay_inputs` is the append-only recording log keyed by (run id, seq): every event (bind, epoch, call start, call end, call recovery, host wake, budget, record failure, barrier clear, seal) carries its payload, payload hash, the previous prefix hash and its own prefix hash; triggers refuse every update and delete. `basket_replay_calls` is the checked call index (one row per call start, one terminal transition, otherwise immutable); `basket_replay_slots` binds each recorded collection to its slot, input fingerprint, envelope hash and log prefix (immutable). Each append commits in its own transaction on the one connection, never inside a save. The two run columns hold the consumed stream position and logical clock, committed with the final save of each cycle; `call_id` links a failure row to its recorded call. Every read verifies the full chain and the indexes against the log before use. All columns are added by idempotent migrations; `storage-inspect` keeps refusing the run file. |
| `quote_replay_runs`, `quote_replay_venue_events`, `quote_replay_decisions`, `quote_replay_checkpoints`, `quote_replay_deadline_results` | the replay run's own file | Offline quote replay records (#1861), written only by `go-trader quote-replay` in a state directory it created; the general schema and migration never create them (the runner creates them in one transaction after it holds ownership, before binding). `quote_replay_runs` holds exactly one marker row (owner, protocol name, and the protocol, source, data, portable config, policy, model and run fingerprints, owner and stored strategy ids, deadline window, cursor). `quote_replay_venue_events` is the paper venue's immutable memory in sequence order (issued ids, placements, cancels, fills, triggers and venue cancels with effect times and digests), each committed before the coordinator observes it. `quote_replay_decisions` (immutable intent per due deadline), `quote_replay_checkpoints` (venue queue state at the deadline) and the pending `quote_replay_deadline_results` row commit inside the quote settlement transaction through `WriteQuoteProgress`, which also moves the cursor and refuses when a persisted fill at or before the applied boundary is unsettled; the result row is completed after `RunDeadline` returns. `storage-inspect` lists replay runs and refuses a file that holds the marker (also an empty marker table), so the scheduler never starts on replay state, also after a copy or rename. |
| `entry_order_intents` | the strategy's partition | One row per live closed-bar entry attempt (#1769): decision identity, provider, account key (non-secret venue account binding since #1771), client order id (and the initial Hyperliquid stop client id), the frozen request, venue evidence, status `reserved`, `submitting`, `settling`, `committed` or `no_fill`, and the replay events a committed source entry still owes. A partial unique index allows one unresolved attempt per strategy; another unique index forbids reusing a client order id. Completed rows are kept; an operator release (#1771) keeps its audit in `evidence_json`. The table was rebuilt once (idempotent, indexes recreated) with `decision_kind` (`candle` for every existing row, or `quote`), `quote_event_id`, and an immutable saved owner `owner_mode` (`''`, `basket` for rows whose request kind is `basket:`, or `pair`) with `owner_group_id`; a `candle` row keeps `interval_ms > 0`, a `quote` row needs `interval_ms = 0`, a quote event and the `pair` owner. Ordinary entry recovery, the operator release and the ordinary transition refuse a pair-owned row from the saved owner as well as from configuration. A progress-only write never advances a pair-owned row's applied totals. |
| `pair_quote_event_uses` | the pair's partition | One row per research pair member and exact quote event (#1862), keyed by partition, stored strategy id and event id (unique, so a member never uses one event twice): pair id, contract fingerprint, slot time, cutoff, input fingerprint, the action that uses it and status `reserved` (written in the reservation transaction with the action and its entry intent), `consumed` (only in a save that writes that member's book) or `released` (proven no fill). Quote uses never touch candle watermarks in `entry_decision_state`. |
| `pair_funding_events` | the pair's partition | Research pair funding (#1888): one row per member and venue event, keyed by partition, stored strategy id, `event_id` (`<provider>:<instrument>:<event_ms>`) and rule version, with unique (partition, stored strategy id, provider, account key, instrument, rule version, event time) and at most one `applied` row per member and event time. Status `incomplete` (reason and detail, written in any pair write), `void` (proven zero inventory), `unresolvable` (a terminal event that the saved inventory history can never settle, with its reason and the unknown position and charge range) or `applied` (the canonical v2 event, or the v3 research event of a funding policy run (#1934), source, evidence hash and USD amount, written only in the save that writes the member's cash and funding trade row); triggers refuse any update or delete of an applied or unresolvable row and any identity change. |
| `pair_funding_inventory` | the pair's partition | Append-only per-member signed base inventory history (#1888) with event time, latest possible event time, visibility, origin, action and fill identity, the position identity after the change (`position_id`, empty when flat), post quantity and contract size; written only with the member's book; update and delete triggers abort; load refuses a hole or a sum that differs from the saved book. |
| `pair_funding_chain` | the pair's partition | Accepted batches of a recorded pair funding source (#1888), keyed by partition, pair and `seq`, with the source id, batch and previous hashes and the canonical batch bytes (`batch_json` v1, or v2 for a funding policy run (#1934), including its oracle observation batches); immutable; resume requires them to be a prefix of the source's chain. Oracle attempts have no table: they derive from the run's own recording tables. |
| `pair_virtual_events` | the pair's partition | Research virtual venue execution history (#1880), keyed by run tag, `member_key_json` (partition, provider, account, instrument and the stored strategy id, translated inside `StateDB`) and `seq`, with unique (run tag, member key, semantic key). Kinds `binding` (always seq 1: contract, cost policy id, source contract, adverse rate, latencies), `submit`, `refusal`, `deadline`, `match`, `consume`, `visible`, `query` and `absence`; each row keeps the client id, source identity, book side, level index, consumed native and base quantity, signed native delta, venue, receipt and visibility times, request hash and the previous and own event hash; update and delete triggers abort. Load, attachment and inspection check the whole chain and refuse a hole, a broken hash chain or another run's records; a write checks the binding row and the rows from the file's last verified tip; an ordinary load refuses any row. |
| `pair_native_resolutions` | the pair's partition | Native fill resolution audits and final markers (#1927), one immutable row per resolved native obligation, keyed by `resolution_id` (`pnz-` + SHA-256 prefix of partition, pair, kind, member and obligation id) with unique (scope, source, pair, kind, stored strategy id, obligation id). Columns: `strategy_id` (stored id, translated inside `StateDB`), `kind`, `obligation_id`, `attempt_id`, `mode`, `decided_by`, `operator`, `reason`, `revision`, `decided_at_ms`, `committed_at_ms`, `record_json` (the whole audit) and `alert_status` (`queued`, then `dispatched`; a queued row is never resent). A row is inserted only in the transaction that removes its obligation: with the member book for a booked recovery, else with the pair progress. Rows load into the runtime and are never adopted from another file. Pair progress version 3 (written only when present) adds `native_attempts`, `native_attempt_seq`, `native_funding_holds` (each optionally carrying `through_ms` and a canonical `proof`) and `native_requests`, which count as recovery evidence like `native_receipts`, and every progress writer is guarded by `checkSavedPairNativeBoundsTx` so a saved bound and proof are immutable. |
| `pair_virtual_allocations` | the pair's partition | Deterministic virtual client identifiers (#1880): run tag, member key and `seq` with a unique `client_order_id` (`vp-<run_tag>-<member hash8>-<seq>`), committed before the id is returned; append-only. The newest allocation, when it has no `basket_member_actions` binding and no venue record, is reused after a crash; a bound or recorded one is spent. A new id is committed only when the newest is spent, and action rows and venue records are never deleted, so allocation reads only the newest row. An ordinary load refuses allocation-only state. |
| `robinhood_order_attempts` | primary, live-only | One row per live Robinhood order attempt (#1815): stored strategy id, account key (a SHA-256 fingerprint of `ROBINHOOD_USERNAME`), coin, origin (`signal`, `circuit_breaker`, `kill_switch`), side, the go-trader `ref_id`, venue order and account ids, the frozen request, venue evidence and status `submitting`, `open`, `unknown`, `settling`, `settled` or `no_fill`. The row commits before the order subprocess starts. Partial unique indexes allow one unresolved attempt per account and coin and per strategy. `settled` is written only in the transaction that saves the book and trade rows. Completed rows are kept. Startup and `storage-inspect` reject an unresolved row in a file that does not own live or owned by no configured live Robinhood spot strategy. A signal sell freezes request version 2 (`close_kind`, `sole_owner`); its evidence keeps the first validated `sizing` write-once, and `requested_quantity` stays the venue order quantity (#1819). A kill-switch or circuit-breaker sell freezes request version 3 (close kind `emergency`, `quantity` = the remaining book asked, never a sole-owner sweep) and its `sizing` also records the total `account_quantity`; recovery uses the saved sent quantity and holds when it is unknown (#1825). |
| `decisions` (replay log) | unchanged | Its own `replay_log_path` file. A source entry published from an `entry_order_intents` row carries `source_entry_event_id` (unique when set), so a retried publication never adds a second decision. |

**Identity translation.** A strategy's process identifier (`id`) and its stored identifier (`storage_strategy_id`, default `id`) are separate namespaces. Every strategy identifier is translated inside the per-file handle, so no caller can skip it. Values that stay in the process namespace: `position_id`, `replay_mirror_watermark_source`, `hedge_for` (a symbol) and `replay_source_id`. A stored row that maps to no configured strategy in its file is an **orphan**: it is reported, never keyed into the roster, and dropped by that file's next full save. Historical trade, closed-position and diagnostics rows with no current strategy keep their stored identifier and carry their source file; nothing assigns them a scope by identifier suffix.

**Journals and rollback order.** One journal per run sits beside the live config, named for the whole key set (`merge-paper-<keys>.journal`), and records each fold's key, partition and instance. Every mode reads every journal for that live instance: an unfinished apply under another fold set refuses a dry run, an apply **and** a rollback, and a completed run that already owns one of this run's drop-in names refuses too. A journal written before folds were recorded yields its key from its file name and the partition `paper`, so an older release's merge still owns its drop-in. Each run retains the config and each drop-in it replaces under its own key set, so stacked runs never share one copy. `--rollback` takes the same `--paper` and `--source` arguments as the apply it undoes, and runs **newest first**: rolling back an older run while a newer merge is installed is refused by name.

**Acknowledgement.** A drained manual action is deleted **by row id inside the transaction that persists its effect**. A failed action keeps its row. A high-water mark is never used, so one file's acknowledgement can never delete another file's row.

**Persistence hold.** Each scope carries its own save-failure counter. Any unacknowledged failure holds position-increasing signals for that scope at all six regime-gated dispatch sites (OKX spot and OKX perps both reach it through `runOKXStep`) and refuses `manual-open` / `manual-add`; closes, trailing stops, ratchet, protection sync and hedge management keep running. Three consecutive failures skip that scope's strategies for the cycle. A successful save clears the hold. In the single-file layout both counters move together, so three failures skip every strategy for the cycle.

**Combined reads.** History, counts, statistics, exports and diagnostics query every required file, translate identifiers, merge, then order and page globally by `(timestamp DESC, scope ASC, rowid DESC)`. A file that cannot be read fails the whole read; no page or map is ever returned with a scope missing.

**Inspection.** `go-trader storage-inspect [--config <path>] [--json] [--require-idle]` prints, per file, the canonical path, the scopes it owns, any held lock and its pid, every mapped strategy with its position count, orphans (flagged when they hold positions), the pending-action count and the risk rows with their latch.
Each mapped or orphan `position_count` is the number of stored rows in `positions` plus `option_positions` for that physical file and stored strategy id, with no strategy-type or quantity filter; the paper-merge summary and per-book output use the same totals.
A missing position table contributes zero, while an existing table that cannot be read fails inspection.
It opens each file read-only, tolerates a pre-#1509 schema, and never migrates, moves or deletes a row.
It also prints each file's closed-bar entry record count (with unmapped rows) and every unresolved live entry attempt with its client order id and hold reason, and each file's Robinhood order attempt count with every unresolved attempt (`rh-…` id, coin, status, `ref_id`, venue order and hold reason; `--json` key `unresolved_robinhood_order_attempts`).
It exits 1 on a rejection: a book in the wrong file, a risk row whose scope the file does not own, an ambiguous legacy row, a duplicate stored identifier, an entry record whose partition the file does not own, or an unresolved live entry attempt held in a file other than the one its strategy is mapped to. `--require-idle` also rejects a file owned by a running process. Startup runs the same check and exits 80 on a rejection, before the first migrating open. A mixed single-file deployment that already holds paper books cannot be split automatically — resolve it by hand or stay single-file.

**Backup and restore.** Stop the service, then copy **every** retained state file with its `-wal` and `-shm` sidecars: `db_file`, `paper_db_file` when set, and each `paper_sources[].db_file`. Restore them together, in the lock order primary, paper, then sources by id, so no partition comes back against another partition's snapshot. `update_resolve_db_exclude` (`scripts/update_helpers.sh`) enumerates the same list, so `scripts/update.sh` excludes every one of them from its rsync; a new state path must be added there or the update overwrites it. There is no transaction across the files: a crash stops every partition at once, and separate files give record separation without fault isolation inside one process. `./go-trader storage-inspect --json` is the discovery command — it names the canonical path, the partitions and the lock holder of every file, so a backup script never has to parse the config itself.

**Service writable paths.** Each folded database directory needs its own systemd directive, and the merge tool writes one drop-in per folded deployment: `50-merge-paper-<id>.conf` for a source and `50-merge-paper-<instance>.conf` for `--paper`. A `/var/lib/<instance>/<dir>` path gets `StateDirectory=`, a deploy-tree path `ReadWritePaths=`. Removing a drop-in without removing its partition leaves the merged unit unable to write that database.

**Access mapping.** After a deployment is folded, its status port and its proxy or tunnel mapping are retired; the combined paper service's port serves every folded partition and the dashboard's partition selector navigates between them. Retire only the routes of folded units — a separate paper-testing deployment keeps its own port and route.

### Folding paper deployments into one paper service

**Paper and live never share a service.** The fold target is always a paper-only service: every mode refuses (exit 15, before any lock or binary) a target config that runs a strategy with `--mode=live`, the same classifier the scheduler uses (`isLiveArgs`). `--rollback` of an earlier fold stays allowed. A live service stays a separate process, and both may read the same shared market feed (§ Shared market feed). In this section "live" in a flag, a message or the journal names the target service, for historical reasons; it never means real-money trading. A paper strategy that mirrors a live one (`replay_sharing: "live_mirror"`) cannot fold, because its live source would have to run in the same config; compose refuses it (exit 21).

`bash scripts/merge-paper-instance.sh --live <instance> --paper <instance>` moves a paper deployment into an existing paper service while both keep their existing state files.
`--paper` refuses (exit 17) when the target's primary file already holds books, a paper risk row or pending actions of the target's own default paper partition, because the fold moves that partition to the folded database; fold such a deployment with `--source` instead.
`--source <id>=<instance>` folds a deployment into its own partition `paper:<id>` instead: it adds one `paper_sources` entry with that id and the deployment's database, aliases every strategy as `<base>-paper-<id>` (numeric suffix when the name is taken) and stamps `paper_source=<id>`.
`--paper` and `--source` may be combined and `--source` may repeat, so several deployments fold in one run.
Defaults: deployments at `/opt/go-trader-<instance>`, configs at `/var/lib/go-trader/<instance>/config.json`, units `go-trader@<instance>.service`; `--base`, `--deploy-root`, `--unit-dir`, `--live-unit` and `--paper-unit` override them.
Both binaries must be one release with the ownership-lock contract, and **both units must be stopped** except for `--diff`.

1. **Dry run (default).** Preflight (both units must already be stopped before any binary runs), then the script holds the ownership lock and the manual-action lock of **both** databases for the whole run and runs each deployment's own `storage-inspect --json` (without `--require-idle`, which would reject the script's own lock) and `inspect --all --json` against **copies** of both configs under its work directory.
   `inspect` and `storage-inspect` load configs through `LoadConfigReadOnly`, which applies a pending migration in memory and never rewrites the file; the script still fingerprints each copy after every run and exits 18 if an older binary rewrote it, and it refuses with exit 18 when the two configs carry different `config_version`s (a config below the current version stays as it is and the daemon migrates it as the service user on its next start).
   It refuses any other lock holder and any rejection; the paper file may hold fewer books than the paper config has strategies (a strategy that never ran a cycle has nothing to move and is reported), but a stored book with no configured strategy is an orphan and refuses.
   It composes `config.json.merge-staged` beside the live config: **every** paper id is aliased as `<base>-paper`, taking the first free numeric suffix (`-paper2`, `-paper3`, ...) when the plain alias is used, whether or not the bare id collides with a live strategy, and `storage_strategy_id` is set to the bare stored id so the stored book is untouched; a paper id that already carries the alias keeps it when the name is free.
   Mirrors get `replay_source_id` in the process namespace, a paper root cadence or ATR method that differs is stamped per strategy, the paper deployment's effective risk limits (read from its own `inspect --all --json`, so loader defaults count) become `portfolio_risk.paper` wherever they differ from the live scope's effective limits, and a live config with no `portfolio_risk` block gets its effective root limits written out explicitly so the merged file keeps the loader's default drawdown limit, `-paper` channel keys are added wherever the paper deployment routes a moved strategy somewhere the merged config does not already send it (a paper value the merged bare key already resolves to adds no `-paper` key, so the merged map keeps the single bare key; the script's route lookup, `merged_channel_route_key`, mirrors the runtime `resolveChannel` fallback, so a change to one must change the other), and `paper_db_file` names the paper database.
   `leaderboard_summaries` is merged, not dropped: the merged list keeps the live entries, then every folded deployment's entries, keyed like the scheduler's own duplicate check (lower-case platform, lower-case trimmed ticker or `*`, channel), so every per-coin board survives the fold; an identical entry appears once, one key with two different entries refuses with exit 21 naming both, and compose and `--diff` print the combined list.
   `channels` and `trade_alert_channels` both skip a redundant `-paper` key when the merged bare key already routes there.
   `SendToPartitionChannels` resolves paper destinations from each partition's own roster with `resolveTradeChannel`, so dropping the suffix key does not widen a paper-scope broadcast.
   `dm_channels` is never skipped: `tradeAlertRoutes` reads a paper strategy's DM destination at one literal key with no fallback — `<platform>-paper:<source id>` for a named paper source, `<platform>-paper` otherwise (`scheduler/notifier.go`) — so dropping that key would silence the paper trade DM.
   `inspect` uses that same exact key (`resolveDMKeyOverMaps`, `scheduler/inspect_cmd.go`), so the proof refuses a staged config that would drop a paper DM route or add one the paper send path was not using.
   Every root key in `regime`, `correlation`, `user_defaults`, `default_stop_loss_atr_mult`, `market_feed`, `shared_market_feed`, `role`, notification flags, `platforms`, `risk_free_rate`, `alert_throttle_interval`, `kill_switch_reset_dm_timeout` and `telegram` is compared in both directions, so a key set only in the live config refuses too (the merged config would apply it to the moved strategies and the per-strategy proof cannot see it); an absent `market_feed` counts as `rest` and an absent `role` as `scheduler`.
   Compose lists every differing non-dropped root key in one refuse (exit 21) and names differing dropped keys in that same report as live-value-kept.
   `--diff` prints that classified list from the two config files alone (no binary, lock, or unit-stop) and does not refuse; it also names compose refuses those files can see (`replay_log_path` when a paper `live_mirror` is present and the merged config would still have a live mirror, discord `-paper` clashes from strategies compose would newly merge), previews the alias every paper strategy would take, prints one `channel-plan` line per discord channel key the compose step would add (and per key it skips because the merged bare key already routes there, the same content compose prints under its own prefix), and states that inspect-based `portfolio_risk` refuses still need a dry run.
   `--align-to-live` (only with `--diff` or `--apply`) copies live's value for each non-dropped non-`strategies` differing root key into `<paper-config>.aligned` and never mutates the paper source; `--apply` composes from that file and the journal records each aligned key's before and after values.
   Proof compares moved strategies against inspect of the aligned paper file, not the pre-alignment source.
   It refuses a zero paper risk override where live is nonzero (zero inherits the live limit; it never disables it), any remaining differing shared root key, and a `-paper` channel that already differs.
   The live binary then proves the staged file: `storage-inspect` must map every paper book with no orphan and `inspect --all --json` must match each strategy's pre-move effective view (cadence, sizing, stops, take-profit ladder, regime inputs, direction, ATR method, scope risk, replay source, notification channels).
   It ends with the service override (`StateDirectory=` for a `/var/lib` paper directory, `ReadWritePaths=` for a deploy-tree one), the post-apply commands, and `VERDICT: READY`.
   Both databases and their write-ahead logs are fingerprinted before and after; a read-only open may create an empty `-wal` or a `-shm`, neither of which holds frames.
2. **Apply.** `--apply` re-checks the config and database fingerprints and the unit states, writes `merge-paper-<key>.journal` beside the live config, retains the prior config as `config.json.pre-merge-<key>` and each prior drop-in as `<drop-in>.pre-merge-<key>`, then moves the staged config and one drop-in `50-merge-paper-<fold>.conf` per folded deployment into place one at a time.
   `<fold>` is the `--paper` instance name or the `--source` id, and `<key>` joins every fold of the run with `+` in partition order (`--paper` first, then sources by id), so two runs that share a fold keep separate retained copies; a journal from a release that recorded no fold lines names its retained drop-in `<drop-in>.pre-merge`.
   A failure between the two steps restores every completed step and exits 4 with both units still stopped; a re-run of `--apply` on an interrupted journal restores first, then starts fresh; a re-run on a complete journal whose files still match is a no-op.
   A dry run reads the same journal: it refuses an interrupted one with exit 24 (run `--rollback` or `--apply`), reports a complete or rolled-back one and continues.
   Any restore (rollback, resume, or the failure path) compares the live config and the drop-in against the fingerprints the journal recorded and keeps a `<file>.merge-edited.<timestamp>` copy of a file that was edited after the apply before overwriting it.
   Lock files the script creates during the run are given the database's owner (or its directory's owner when the database does not exist yet), so the merged service can open them; pre-existing lock files keep their ownership.
   Apply never starts a service or places an order.
3. **Cutover.** `systemctl daemon-reload`, `systemctl disable go-trader@<paper>.service`, `systemctl start go-trader@<live>.service`, then `journalctl --namespace=+go-trader -u go-trader@<live>.service -n 50 | grep '\[storage\]'`: the start prints the layout, both resolved paths, every `alias <role>: <process id> -> <storage id>` line and `[config] portfolio scopes: live=N paper=M`. Retire the paper instance's `status_port` and any tunnel mapping that pointed at it; the combined process serves both scopes on the live port. Every moved paper strategy now answers to its alias, so Discord manual commands, the dashboard and the per-strategy operator surfaces take `<base>-paper`. The bare id names nothing in the merged process. `/go-trader-paper-to-live` changes only the strategy args and keeps the id, so a promoted strategy keeps its `-paper` name until an operator renames it (set `storage_strategy_id` to the stored id when renaming).
4. **Rollback.** With both units stopped, `--rollback` restores the retained config and drop-in and never touches a database, so the paper strategies come back under their bare ids; the paper source config is never rewritten. Records the combined process wrote to either file after cutover stay in that file.

**Folding into a new paper service.** `--new-target <name> --status-port <n>` (exclusive with `--live`) folds every `--source` and the optional `--paper` deployment into a new template service `go-trader@<name>`, so no folded deployment becomes the combined one and keeps a coin name and port.
`--status-port` must lie in the range the scheduler loads, `statusPortMinimum` through `65535 - statusPortMaxAttempts + 1` (1024 through 65531 today), which the script reads from `scheduler/server.go`; any other value, or an unreadable bound, exits 2 before any temporary file or check, `--diff` included.
The target's own `live` and default `paper` scopes hold no strategy unless `--paper` is given.
Preflight refuses with exit 19 when `/opt/go-trader-<name>`, `/var/lib/go-trader/<name>`, the unit file or its drop-in directory already exists, when the unit is enabled, masked, active or failed (the refusal names `systemctl reset-failed` for a failed unit), when a folded unit does not load from the installed `go-trader@.service`, when the folded units run as different users, when the installed template or journald namespace config differs from the release's copy, when the first folded deployment has no git `origin`, has uncommitted changes to tracked files, or runs a commit that no `origin` branch contains (a branch checkout needs its own `origin` branch to contain it; a detached checkout uses the default branch, else the first `origin` branch that contains it), when a running process has bound `--status-port`, and when two folded `.env` files give one variable different values (the refusal names the variable, never a value).
Different releases still exit 11 and different `config_version`s exit 18.
The root settings come from `--root-from <instance>` (default: the first folded deployment): the target config is that deployment's config with no strategy, no `paper_sources`, no leaderboard entries, `status_port` from the flag and `db_file` `/var/lib/go-trader/<name>/state.db`, which starts empty.
An absolute `log_dir` inside the root deployment's tree moves to the same path under `/opt/go-trader-<name>`, and the dry run prints the rewrite.
In both modes compose refuses (exit 21) when any value of the merged config names a path inside a folded deployment's tree, except the folded database routes (`paper_db_file`, `paper_sources[].db_file`); `--diff` previews the same refusal.
Every refuse-on-difference key must then agree across all folded deployments (one conflict report, `--align-to-live` aligns to the root deployment), the other dropped keys keep the root value and the dry run lists every differing source value per fold, root `portfolio_risk` is the root deployment's block, and every source keeps its own effective limits in `paper_sources[].portfolio_risk`.
The target has no strategy of its own, so compose reads the root effective risk view from the root deployment's paper-scope inspect; the per-strategy proof still compares every moved strategy's scope risk.
The dry run stages the config and the drop-ins in a temporary directory it prints and keeps, proves them with the first folded deployment's binary, and creates nothing else except the database lock files.
`--apply` creates `/var/lib/go-trader/<name>/` (0700) holding the journal `merge-paper-<key>.journal`, clones the first folded deployment's `origin` into `/opt/go-trader-<name>` on the `origin` branch preflight chose, at that deployment's commit, installs `config.json` (0600, owned by the service user), links `scheduler/config.json` to it, writes `.env` (0600) from the folded `.env` files (`GO_TRADER_SERVICE` that names a folded unit becomes `go-trader@<name>.service`), builds the tree with `scripts/update.sh --rsync-from <first folded deployment>`, requires the new binary to report the folded release and the new tree's tracked files to match that commit (so a later plain `update.sh` can pull it), gives the tree to the service user, proves the staged config again with the new binary, installs `go-trader@.service` and the journald namespace config only when absent, and writes one drop-in per fold under `go-trader@<name>.service.d/`.
It never enables or starts a unit and never changes a folded config.
The printed cutover is `daemon-reload`, `disable --now` for every folded unit, `enable --now go-trader@<name>.service`, the `[storage]` boot lines, and retiring every folded status port.
A failed apply moves what it created aside and exits 4; a re-run with a complete journal whose files match is a no-op, and a journal with no `complete` line refuses with exit 24 until `--rollback`.
`--rollback --new-target <name>` with the same fold arguments refuses while the unit is active (exit 14), and refuses (exit 24) while `/var/lib/go-trader/<name>/` holds the journal of a later `--live <name>` fold that is not rolled back, since moving the directory aside would strand that fold; otherwise it disables the unit, clears its failed state, removes the drop-ins, moves `/opt/go-trader-<name>` and `/var/lib/go-trader/<name>` aside as `.rolled-back-<run_id>` (the new `state.db` and the journal move with it), and prints the commands that enable and start every folded unit again.
Folded configs are never changed in this mode, so nothing is restored, and records the combined service wrote stay in each source database.
Read-only `inspect` loads a config whose primary file does not exist yet from its other files, the way a first start does, which is what lets the proof see every folded book before the new primary exists.

**Exit 79 during a handoff.** A scheduler that starts while the script holds a lock exits 79 naming the holder pid, `RestartPreventExitStatus` keeps it down and the owner is paged. That is the intended guard; run the script only with both units stopped and start the live unit after apply. Back up both files first; there is still no transaction across the two files, a process crash or stuck cycle stops both modes, and the script never runs `--once`, never opens the migrating writer, and never treats `--probe-only` as proof of ownership or write access. Exit codes: 2 usage, 3 lock contention, 4 restore failed, 5 a source file changed before apply, 10 to 19 preflight refusals (18: a config migration is pending or the configs carry different versions; 19: a `--new-target` refusal), 20 inspection, 21 compose, 22 proof, 23 override, 24 journal state.

### Cutover checklist and the recovery boundary

Run in order. Nothing outside the staging area changes before step 5.

1. **Inventory.** For every unit, record the instance, its config path, `db_file` (and `paper_db_file`, `paper_sources[].db_file` if already folded), `status_port`, the binary version and the proxy or tunnel mapping. `./go-trader storage-inspect --json --config <path>` answers the storage half per deployment.
2. **Update every deployment to one release.** `bash scripts/update.sh --restart` per instance, never a Go-only rebuild: Go and Python share one argv contract per commit. Confirm `./go-trader --version` matches across deployments and that every config carries the same `config_version`; the merge refuses with exit 18 otherwise.
3. **Preview.** `--diff` reads only the config files and needs no stopped unit. Resolve every refuse-on-difference root key it names, by editing the paper config or with `--align-to-live`.
4. **Prove.** Stop **every** unit named in the run, then the dry run (no `--apply`). It holds each database's ownership and manual-action locks, proves the staged config with the live binary's own `inspect --all --json` and `storage-inspect --json`, and ends at `VERDICT: READY`. Databases are fingerprinted before and after; the dry run never writes one.
5. **Apply.** The same command with `--apply`.
   The **recovery boundary** is the journal line `config begin`, which the apply appends before it retains the live config and installs the staged one; `config done` is appended only after the install.
   Before `config begin` nothing outside the staging area and the journal itself has changed and there is nothing to roll back.
   From `config begin` on, the live config may already hold the merged config while `config done` is still missing, and each drop-in behaves the same way from its own `override begin` line (`override begin.<id>` for a source).
   The restore path keys on the `begin` markers, so `--rollback` and a re-`--apply` (which restores the retained files first) recover from the retained copies in either window.
   Read a journal that carries `config begin` as material that may already be installed; never start the merged unit or set that journal aside on the strength of a missing `config done`.
   An interrupted apply restores what it installed and exits 4; if that restore also fails it prints CRITICAL with the retained paths and stops, and the deployment needs a hand repair before any further run.
6. **Restart and verify.** `systemctl daemon-reload`, disable each folded unit, start the live unit, then check the `[storage]` boot lines for the layout, every resolved path and every alias, `[config] portfolio scopes`, that each partition's books and risk row came back, that protection is armed on every open position, that paper signals are produced on the next cycle, that replay pairing still holds and that a trade alert reaches each partition's channel. **`VERDICT: APPLIED` is not proof that trading resumed**; the boot lines and the first cycle are.
7. **Retire.** Only after step 6 passes: disable the folded units, retire their status ports and their proxy or tunnel mappings, and keep every source database, its writable-path drop-in and the retained copies. A folded unit that restarts would be a second owner of a database this process holds, which is what exit 79 guards.

**With `--new-target`.** Step 1 also picks the new name and a free status port, and step 3 runs `--diff --new-target <name> --status-port <n>` against the root deployment.
Step 4 proves with the first folded deployment's binary.
In step 5 nothing that carries the new name exists before `/var/lib/go-trader/<name>/` and its journal are created, and every later path is recorded there before it is written (`deploy begin`, `config begin`, `env begin`, `build begin`, `template begin`, `override begin.<id>`), so `--rollback --new-target <name>` with the same fold arguments moves aside whatever the interrupted run created.
No folded config or database is ever written.
Step 6 is `systemctl daemon-reload`, `systemctl disable --now` for every folded unit, then `systemctl enable --now go-trader@<name>.service`; the `[storage]` boot lines must show `primary -> /var/lib/go-trader/<name>/state.db` and one `paper:<id> -> <source database>` line per fold.
Until step 7, `--rollback` and the printed `enable --now` commands bring every folded unit back on its own port.

**Databases are never rolled back.** `--rollback` restores the config and the drop-ins and never opens a database for writing, so records the merged process wrote after cutover stay in the file that owns them. Restoring a database snapshot is a separate, manual step under § Storage Ownership Backup and restore, with every retained file restored together.

---

## Portfolio Kill Switch And Latch Ownership

**The latch is partitioned by mode.** One scheduler holds two portfolio scopes, `live` and `paper`, decided only by `--mode=live` in the strategy args. Each scope keeps its own peak, drawdown, latch, events, daily-loss ledger, notional total, exposure total and correlation model, and each is evaluated once per cycle over its own strategies. A paper drawdown can never latch live, and a live drawdown can never latch paper. Only a scope with at least one configured strategy is evaluated, so a single-mode deployment evaluates one scope, and the operator surfaces name it. On upgrade, an existing unscoped `portfolio_risk` row moves into `live` when the config holds any live strategy, else into `paper`; the move is written back on the first boot and is a no-op afterwards. With `paper_db_file` set, each file's unscoped row is placed from that file's own owned scope (§ Storage Ownership).

A live latch runs the exchange close plan over the live roster only. A paper latch closes paper books virtually at mark, sends no exchange order, and posts to the channels the paper strategies resolve to; it never auto-resets without an owner. Because paper has no wallet fetch, its equity reading is always trusted, so a paper scope never shows a substituted reading or a deferred latch.

The portfolio kill switch latches on drawdown and halts new trading until it is reset. **Exactly one measurement owns the latch each cycle** — there is no tie-break:

- **Equity drawdown owns it** when the equity guard is armed: a portfolio total is available AND the recorded peak is above zero. The kill switch then trips on equity drawdown over `max_drawdown_pct`.
- **Perps margin drawdown owns it** only when the equity guard is not armed.

When equity owns the latch, a margin drawdown over the limit is a throttled WARNING, not a trip: per-strategy circuit breakers own margin protection. The warning is coalesced by `alert_throttle_interval` and re-fires early on a band entry or a 1-point drawdown escalation.

**Untrusted readings.** A cycle's total is trusted only when the pooled equity read is complete AND no portfolio-value fallback and no stale risk balance were used. On an untrusted cycle the latch stays with equity, but:

- The peak never ratchets up, so a bad total cannot inflate the peak.
- The equity drawdown reading is FLOORED at the last reading, clamped to `max_drawdown_pct` so the floor alone can never latch.
- The substitution is persisted and flagged as `drawdown_reading_substituted`. **Label it on every operator surface** — the number is carried forward, not measured this cycle. The warning DM marks it as "carried forward; balance substituted this cycle, does not reconcile with the figures below".

**An untrusted over-limit reading defers the latch; it never vetoes it.** The first such cycle records the timestamp and a `latch_deferred` event naming the untrusted basis. While the run is unbroken and under 15 minutes old, the full-book latch is held and per-strategy circuit breakers are the active protection. Past 15 minutes the latch escalates and the reason names the untrusted basis and the deferral. A trusted reading landing first clears the timer. The deferral is loud: the log line is `[CRITICAL]`, and the warning DM bypasses the throttle for as long as the deferral stands. `/go-trader-circuit-breakers` shows the deferral, when it started, and when it escalates.

Reset is owner-DM only and clears one scope. With no DM owner configured, the live scope auto-resets only after a confirmed-flat close, and the paper scope auto-resets right after its virtual close. The reset DM names the scope in its header, and carries the drawdown reason, the trader-instance label, the HL wallet address (live prompts only), and a protection-gap warning when the close plan has not confirmed flat. Reply `reset` while one scope is latched. While both are latched, one prompt covers both scopes: reply `reset live` or `reset paper`; a bare `reset` is refused and names both, and the scope still latched is re-prompted next cycle. One single-flight prompt waits at a time, so one owner reply is never consumed by the wrong waiter. `kill_switch_reset_dm_timeout` sets how long that prompt waits (empty = 6h).

**Auto-reset.** Once every platform is confirmed flat the next cycle clears virtual state and resumes trading, posting `Virtual state cleared. Kill switch auto-reset; trading will resume next cycle.` Auto-reset also needs every resting limit order resolved (see below) and no operator-required venue outstanding.

**Resting limit orders are cancelled before the flatten.** The kill-switch close cancels every `pending_limit_orders` row first, keyed on the ROW rather than a position, under a 60-second deadline. Each row goes cancel → status check → delete; a row whose outcome cannot be resolved clears the confirmed-flat flag and blocks auto-reset without an owner. A row carrying an unadopted fill is never auto-deleted. Cancellation and adoption are separate eligibilities: a row belonging to a strategy that is absent from the config, or is no longer `type=manual`, is still cancelled even though it can no longer be adopted. Ordinary limit-order reconciliation is never gated on kill-switch state.

**Multi-strategy HL coins.** Kill-switch fills split by virtual quantity at snapshot time, and the split fails closed. If HL flattens to about zero, a sole-stop trigger fires with the residual matching non-owner peers, or a single take-profit tier fills externally, the next cycle closes the affected virtual peers automatically; an ambiguous gap stays a gap.

Warn-band messages repeat while the drawdown sits inside `portfolio_risk.warn_threshold_pct`. Silence them by resolving the drawdown or changing the threshold.

Drain and live-execution failure alerts: `journalctl --namespace=+go-trader -u go-trader -n 100 | grep "liveExec\|drain"`.

---

## Hyperliquid Liquidation Guard

A stop-loss trigger placed past the exchange liquidation price can never fill: Hyperliquid force-closes first, at liquidation-engine pricing. The guard makes that geometry unreachable.

**Boot check.** For every live isolated-margin HL perps strategy, a stop distance at or beyond the bankruptcy distance (`100 / leverage` percent) fails the load with a message naming the field, the bound, and the leverage. It covers `stop_loss_pct`, the price-percentage derived from `stop_loss_margin_pct`, and the `max_drawdown_pct` fallback. Cross-margin strategies are exempt. Run the same audit across the fleet before an update with `bash scripts/check-hl-stop-bankruptcy-bound.sh`, which reads raw JSON, mirrors the loader's stop-owner resolution, and exits 1 on a finding.

**Runtime clamp.** Live only: paper triggers are never clamped. The guard reads the per-coin liquidation price and matches it against the coin's net side; a side mismatch means unknown and the coin is skipped. It **clamps, never refuses to arm**, and it tightens **one way only**: a trigger past liquidation moves to 0.5% inside it, and a replacement is submitted only when it is strictly tighter than what is resting. The liquidation price is never persisted, so 0 always means unknown. Geometry that cannot be clamped is refused rather than guessed. Before the dispatch of each cycle an audit tightens every stop owner; when nothing is due, an off-cycle pass runs at half the shortest live HL interval, floored at 60 seconds, over live HL perps and `manual`.

**Refusals that protect peers.** The audit will not touch a coin whose recorded size across live strategies exceeds the on-chain snapshot — moving a reduce-only trigger there could close a peer's real position. It reports that as `not reconciled`: reconcile the coin and the audit heals it on the next pass.

**Alerts.** Each outcome DMs the owner and posts to the channels, deduplicated per strategy and symbol and re-sent on an action change or after `alert_throttle_interval`. The action names what happened:

| Action | Meaning | What to do |
| --- | --- | --- |
| `clamped` | The trigger was tightened to just inside liquidation | Nothing. Lower the leverage or the stop distance so the configured geometry is reachable |
| `replace deferred` | The replacement could not be placed; the ORIGINAL stop is still resting | Nothing. The scheduler retries next cycle |
| `protection lost` | The old trigger was cancelled and the replacement did NOT rest — **the position has no exchange-side stop** | Act now. The message names when the scheduler re-arms |
| `re-armed` | A position with no stop got one | Nothing |
| `re-arm failed` | The re-arm did not rest — **no exchange-side stop** | Act now |
| `not reconciled` | Recorded size does not match the on-chain snapshot, so the audit did not touch the order | Reconcile the coin |
| `SL filled` / `exited` | The original stop already fired, or the replacement filled at submit | Nothing. A replacement that filled at submit is booked by the step that placed it, which sends its trade alert and counts it in that cycle. A fill of the original stop is booked by the reconciliation pass |
| `outcome unknown` / `placement unknown` | The result could not be read; an order may be resting untracked | Verify the order book on Hyperliquid. Recorded state is kept and nothing is re-placed |

An unreadable outcome always keeps the recorded state. A cancel that leaves nothing resting gets exactly one in-cycle retry, and only when the placement was positively rejected — classification comes from what actually rests, never from error text.

---

## Model-Only Close Reconciliation

When a circuit-breaker close books a row from the model rather than a real fill, the row carries `fee_source='reconcile_adjustment'` and no exchange order ID. Once the real Hyperliquid fill lands, the scheduler corrects that row **in place** — quantity, fill VWAP price, exchange fee, gross realized PnL — instead of writing a second close. Partial fills accumulate against the closed basis over successive cycles, and the row stops accepting corrections after 48 hours.

If the coin goes flat on-chain while the row still covers only part of the close, the residual was finished by another mechanism such as a resting stop. That raises an owner alert, at most once a day per strategy and symbol, saying that the trade row, `closed_positions`, and cash are inconsistent. Fix it with `backfill trade-ledger` or reconcile by hand.

---

## Basket Coordinator

Root `baskets` (#1781) is default-off. One basket coordinator ranks a fixed universe of ordinary Hyperliquid perps paper strategies through a registered basket evaluator, freezes one target set per rebalance deadline, sends one bounded elective member order at a time and recovers after restarts without sending a second order. **Live basket dispatch is refused** at load, at every dispatch and in the operator routes (`paper-to-live`, `add-strategy`, `resolve-entry`), with or without `allow_no_edge: true`; enabling it needs a separate promotion change. `config.example.json` ships no basket.

Basket entry fields: `id` (lowercase, `[a-z0-9_-]`), `ranker` (a registered basket evaluator), `params` (the evaluator's declared parameters only), `members` (strategy ids), `rebalance_interval_seconds` (a multiple of 60 and of every member candle interval), `evaluation_window_seconds` (shorter than the interval), `ranker_timeout_seconds` (at most the window and 600), `lookback_bars`, `gross_budget_usd`, `max_transition_net_usd` and `max_coin_notional_usd` (both at most the gross budget), `min_eligible_members` (at least 2), optional `holding_horizon_seconds` (an integer in [0, 2147483647]; absent or 0 turns expiry off; null, booleans, fractions, negatives and larger values are refused), and `eligibility` with `source`, `version`, `min_history_bars` and one `listings` fact per member coin (`listed_at`, `known_at`, optional `delisted_at`, RFC 3339). Unknown keys at any level are refused.

A member names its basket with `basket_id`, uses the `basket_member` positional strategy (`args: ["basket_member", "<COIN>", "<timeframe>", "--mode=paper"]`), sets `closed_bar_decisions: true`, `direction: "both"`, a positive `capital` and an explicit native `close_strategy`. It keeps its own state row, close owner, stops and protection. Load refuses: two members on one coin; any non-member strategy holding a member coin in the same partition; members in different partitions or paper sources; `market_feed: rest`; an open-strategy close fallback, `avwap_stop`, scale-in, inversion, replay, hedge, Hurst gate, higher-timeframe filter, regime gates, LLM entry analysis and risk- or margin-based sizing on a member; and a basket evaluator used as a positional, open or close-fallback strategy anywhere.

**Members are management-only.** Their check runs with `--basket-member` (batch slot `basket_member: true`): closes, indicators and protection run, no entry evaluator runs, and the output must echo `basket_member: true` or Go treats it as a script error. Go also drops any position-increasing member signal. Only the coordinator places member increases.

**Ranking.** At each deadline the coordinator waits for a sealed snapshot whose decision cutoff is at or after the deadline (a cycle snapshot sealed before the boundary reserves nothing, so a later cycle in the window ranks it), reserves the deadline row, builds the sealed payload with the frame of every member the declared listing evidence does not exclude at the deadline (`lookback_bars` + 1, timed), and runs `shared_scripts/run_basket_ranker.py` as a read-only subprocess with its own one-slot semaphore, a deadline at the end of the evaluation window and a timeout no longer than what remains.
The runner checks the snapshot id, generation and decision cutoff, applies the listing evidence known at the deadline before it reads any frame (missing or not-yet-known evidence holds the whole set; a known later listing, a delisting or too little listed history excludes that member with a reason, and an excluded member needs no frame or mark), proves each remaining member's decision candle closes at the deadline with finite prices and gap-free closed history, and validates the evaluator output (exact eligible keys, finite scores and weights, absolute weights summing to at most 1, both sides populated).
Go validates the echo again, refuses an eligibility record that disagrees with its own reading of the listing evidence, refuses a per-coin weight above the cap or a target net above `max_transition_net_usd`, and freezes signed weights, target notionals and lot-floored quantities from the deadline candle close.
Any missing, stale, short, malformed or mismatched input, a timeout, a failed process, unreadable or invalid output, or an expired window records a terminal hold for that deadline; existing positions, stops and recovery continue.
A recorded deadline is never reranked, including after a restart.
**Request version 2 and allocation context.** The ranker request is version 2 in Go and Python together; a mixed pair fails closed.
It carries one `allocation_context` object copied from the saved basket contract for that deadline: `gross_budget_usd`, `max_coin_notional_usd`, `max_transition_net_usd`, `lookback_bars` and `min_history_bars` (the old top-level `lookback_bars` and `min_history_bars` are refused).
The runner refuses, as invalid with exit 1 and before any evaluator runs, a version 1 request, a missing context, missing or unknown context fields, booleans or non-finite or non-positive numbers, a cap or net bound above gross, `lookback_bars` outside [2, 5000] and `min_history_bars` outside [1, `lookback_bars`]; listing history and closed-bar selection read only the context, and the input fingerprint includes the request version and the whole context.
Every parsed-request hold or targets result echoes version 2. The coordinator stays the owner of the saved contract and targets; the context is a request-local copy.
**Order.** After parsing, the runner resolves the registered evaluator, applies paper admission, the unknown-parameter check and `validate_params`, then calls the optional `basket_preflight(effective_params, allocation_context)`, all before the envelope, listing, population and frame checks, so a missing, short or stale frame never hides a configuration error.
The evaluator receives the frames, the eligibility record, the read-only context (mutation raises and returns invalid) and the effective parameters as separate arguments.
The Go coordinator can still hold before Python starts when the sealed snapshot cannot supply a member frame.
**Evaluators.** `basket_scripted_fixture` is an integration fixture that returns scripted weights (no preflight).
`cross_sectional_momentum` (#1787) ranks the eligible members by trailing closed-bar return, longs the top quantile and shorts the bottom quantile, weights each side by inverse trailing volatility to one half of the gross budget, and caps each coin at `max_coin_notional_usd / gross_budget_usd` with same-side redistribution; it emits weights on a 2^-52 grid with equal side totals, so the target net is exactly 0, and it applies the coordinator's cap, gross and net checks itself.
An infeasible side under the cap, short history, a non-positive or non-finite price, a non-finite return or zero volatility on any eligible member holds the whole set; its preflight refuses a `lookback_bars` too small for its lookbacks. The frozen definitions are in `backtest/candidates/cross_sectional_momentum_1787/SIGNAL_SPEC.md`; the #1792 basket replay study (`REPORT.md` there, the registry `edge_ref`) records `study_fail`.
`cross_sectional_reversal` (#1793) uses the same definitions, holds and registered preflight with the tail sides reversed: it longs the bottom quantile and shorts the top quantile; the frozen reference is `backtest/candidates/cross_sectional_reversal_1793/SIGNAL_SPEC.md`, and the #1790 basket replay study (`REPORT.md` there, the registry `edge_ref`) records `study_fail`.
Both return rankers share one implementation in `shared_strategies/open/basket_allocation.py` (`trailing_return_rank_targets`); the keyword-only `long_tail` selector of `allocate_long_short` (default `top`) picks which of the two tails from `select_tails` is long, so tie membership never changes and scores are never negated. It is internal to each evaluator, never a ranker parameter or context field.
All three are `no_edge`, absent from every single-symbol registry, and run only in explicit paper mode.

**Transitions.** Reductions run before increases; a reversal reconciles flat first.
Each action is checked at both reachable endpoints (no fill and a full fill of its remaining quantity) against the gross budget, the absolute net bound and the per-coin cap, using current sealed marks; a missing mark on a member that holds a position or has a nonzero target holds every elective action, and a flat member with a zero target needs no mark.
A member that holds a position in a coin the listing evidence excludes, with no mark, also raises one CRITICAL alert.
An increase needs an in-limit starting state; a reduction may run from a breached state if it worsens no bound.
An action that does not fit is cut to the largest legal lot-sized tranche; increases prefer the side that reduces net exposure.
Every increase also needs an unused closed-bar decision on that member and passes the partition kill switch, persistence hold, daily-loss, notional-cap, exposure-cap, pause, circuit-breaker and paper-margin gates.
A partial increase consumes its candle; the underfill waits for a later candle and is recorded as a deviation.
Excluded or failed allocations are never moved to other members.

**Settlement and recovery.** An action is reserved durably, marked submitting durably before the order call, then settled.
Paper actions book through the existing perps open, scale-in and close owners at the sealed mark and commit book, consumed decision and basket progress in one partition save; a save failure stops further elective actions until that file saves.
A live-shaped transport (used only by controlled integration tests) also reserves an entry intent with the same client order id.
An unknown outcome leaves the action unresolved and holds every other elective order until a query by client order id resolves it; a rejected leg freezes increases and starts reduce-only recovery.
A stop, take-profit, kill switch, liquidation or operator close that changes a member book is recorded as a protective exception (never a bounded success): increases freeze, the breach is recorded and alerted, and reduce-only recovery restores the net bound.
The coordinator step holds the state lock `mu` for every read and write of its runtime records and every basket row write, so a full save from another goroutine never sees them change; it releases `mu` only around the shared-feed snapshot fetch, the `--once` ranker wait and each venue submit or query, and it sends alerts and trade alerts after it unlocks.
A due deadline that a save failure, a failed reservation write, a settlement that is not durable yet or an unresolved action keeps from reserving retries once a minute, never every second.
At startup the coordinator refuses a changed contract while a rebalance or member position is open, refuses a removed basket that still holds recovery evidence, and resolves an interrupted deadline as a hold.

**Holding horizon (#1794).** `holding_horizon_seconds` is a coordinator lifecycle field: it is serialized in the contract with `omitempty` (a basket without it keeps a byte-identical contract and fingerprint, and the contract version stays 1), it never reaches the ranker request, adding or changing it while a member position or rebalance is open is refused at startup, and reload refuses every `baskets` change.
The first confirmed entry fixes the lifecycle: the booking owner books it at the coordinator's cycle time (a scoped book clock on the member state; ordinary callers keep the system clock), and the lifecycle copies the booked position id and opening time, so expiry is due at entry plus the horizon. Partial fills, same-side adds, reranks, retries and restarts never move that anchor. The lifecycle clears only when a durable save of that member's book shows it flat, including after a native stop, take-profit or kill-switch close; a later position starts a new lifecycle.
Every expiry, lifecycle, suppression and expiry-action time comes from the cycle time.
Each step reconciles any unresolved action, then processes due expiry before it collects or starts a rank and before any elective increase.
Expiry needs no rank and no member frames, and it runs when the latest rebalance is held, completed or absent.
Members due in one cycle form one reduction set ordered by net relief, then member id; each tranche uses the existing reduction legality and lot floor, with a deterministic action id from the partition, basket, member, position id, expiry time and attempt, and a client order id reserved before submission.
Both the main step and the early shared-feed expiry pass load the venue lot sizes before they size a tranche; when the lot sizes are unavailable and the full close is outside the bounds, the close holds with one WARN that names the lot outage, and the bound alert key stays unused.
A restart queries an unresolved attempt by its saved client order id and never resends it; a confirmed partial or no-fill outcome lets the next attempt take the next durable attempt number and a new client order id.
A rejected attempt or a held paper booking retries with a bounded backoff (1 minute doubling to 30 minutes; the third failure alerts CRITICAL) and never drops the obligation; a confirmed fill that cannot be booked stays unresolved with a CRITICAL alert.
While an obligation is open every increase holds, protection continues, and pause does not block the close. A latched kill switch keeps its flattening authority, so expiry waits for it. A missing held-member mark (retry at the next supported shared-feed key, else after 1 minute), a blocked tranche (one alert that names the bound, retained in the lifecycle across completed rebalances and restarts; retry after 1 minute) or a failed save holds the close and every increase.
Expiry marks come only from sealed snapshots: websocket uses its marks-only seal; the shared feed runs a due close right after the cycle seal is fetched (before the member checks, because a shared seal's marks stay fresh for only 15 seconds) when that seal holds a fresh mark for every held member, and the coordinator step otherwise fetches a marks-only snapshot at the latest fresh key of the consumer schedule after the last key it tried (never an expiry timestamp), with the usual primary and backup policy. Missing, stale, evicted, pre-start, incompatible or degraded seals never fall back to a private price.
When an obligation becomes due its suppression deadline advances to cover the current scheduled rebalance boundary and every recorded or in-flight rank deadline, and it is saved before any target is consumed; a member's effective target is zero for every deadline at or before it (the frozen targets stay unchanged), a rebalance does not complete while a close is open, and only a later eligible deadline can open a new position. The suppression deadline survives the flat commit, restarts and the pruning of old rebalances.

### Research pairs (#1862)

A research pair is one isolated cross-venue perpetual spread with two native members: one Hyperliquid `perps` member and one OKX `perps` member with `swap` semantics, for one underlying, in one paper partition or one paper source. It extends the basket coordinator, its group records, the entry-intent owner and the book-save transactions; it adds no second coordinator, booking, settlement, decision or replay authority. Issue #1863 supplies the real venue transport, joined quotes, depth, venue valuation and conversion and the booking change for zero and negative confirmed fees; issue #1875 supplies the spread policy evaluator, its process runner and parameter validation (Pair policy section below), #1877 the authoritative rounded bounds, #1878 the canonical registration and #1881 and #1882 the replay wiring.

**Admission.** Root `pairs` and a member `pair_id` are research-only.
Every ordinary route refuses them in every mode, paper with acknowledgement included, before any configuration migration can write: ordinary, read-only, probe and ledger loads, inspection admission, reload, initialization (`pair_id` has no wizard or `InitOptions` support) and operator routes (the add-strategy command refuses a production-refused pair policy name through `productionRefusedStrategies` before the pair-token check, which refuses `pair_member`; the entry operator refuses a pair member).
Refused references are the root `pairs` key, a strategy `pair_id`, the `pair_member` positional strategy or open strategy and the `--pair-member-json` flag.
An ordinary load also refuses a state file that holds pair records.
Only the unexported research-admission context activates a pair: `newPairResearchAdmission(doc, runID, stateDir, resume, deps)` registers a one-time admission, scans the whole document strictly and checks the raw `pairs` entry (`pairResearchRawPair`, which the controlled fixture also calls), loads the configuration in memory through the internal loader, builds the contract, binds the frozen evidence source (`deps.Evidence`, Evidence source below) and checks the transports before any write.
Only then does `checkStateDir` claim the state directory: a new run creates it with one `os.Mkdir` (an existing path, an empty directory included, refuses) and creates `pair-research.json` exclusively and synced (Offline pair replay below); a resume needs the saved `pair-research.json` run manifest with the same run id, pair id, contract fingerprint, `pair_v` and `evidence_digest`), a synthetic account binding (`synthetic:<name>`) and a virtual transport (a `pairVenue` whose `Virtual()` is true and `LiveShaped()` false).
No configuration field, flag or environment variable grants it.
Issue #1864 wires it into the isolated replay runner and its paper extension.
A controlled live-shaped fixture (test-only) keeps both members in the live partition and replaces every dependency before it opens a member; only it accepts the venue account formats and builds live-shaped venues (Pair bounds, transport and the position barrier below).

**Contract.** A pair entry carries `id`, `underlying`, `policy` (validated through `pairPolicyStrategies`, `pairPolicyParamKeys` and, for raw parameter values, `pairPolicyParamValidators`; `cross_exchange_perp_arbitrage` is the one research pair policy beside the test-only fixture policy since #1875), `params`, `members` (`strategy_id`, `account_key`, native `instrument`, `source`, `modeled_delivery_ms`, `cost_policy`, `meta`: native unit, base asset, contract size, quote, settlement and price currency, tick, lot, minimum quantity and notional, trading status, linearity, position mode, margin mode, maximum leverage, initial and maintenance margin rates, price rule, size decimals, tick and lot step text, fee currencies), `limits` and `book_need` (Contract version 2 below).
The member identity key is `(partition, provider, account binding, native instrument, member id)`; the underlying is a separate aggregation key.
Load refuses: anything but exactly two members on two venues, a different underlying, a missing or duplicate member, mixed partitions, a live member outside the fixture, conflicting venue-account-instrument owners (a standalone strategy trading or hedging the underlying in the partition included), baskets in the same research config, a member with `basket_id`, an unsupported mapping (Hyperliquid: base units, contract size 1, `one_way`, `cross` or `isolated` margin, the bare coin, USDC currencies; OKX: contracts, `net` position mode, `cross` margin, `<UNDERLYING>-USDT-SWAP` with USDT quote, settlement and price currencies).
Load also refuses a member without the `pair_member` positional strategy, explicit `--mode=paper`, `direction: "both"`, finite positive `capital`, an explicit native close and a resolvable stop owner (Hyperliquid: a stop field or the default ATR stop; OKX: `atr_stop` or a close with `sl_after`), and any independent open evaluator, scale-in, inversion, hedge, replay mirror, regime or Hurst gate, higher-timeframe filter, closed-bar decisions, LLM entry analysis, `risk_per_trade_pct` or `margin_per_trade_usd`.
Pair identity, membership, policy, parameters, limits, accounts, instrument mappings, source and storage are restart-required; reload refuses every change, and startup refuses a changed contract while a position, an unapplied fill, a funding gap or recovery evidence remains, and a removed pair that still holds recovery evidence.

**Configuration limit rules** (`pairOperationalLimitErrors`, called by `pairConfigErrors`; #1869 added `quote_age_ms`, `collection_timeout_ms`, `receive_skew_ms`, `envelope_max_bytes` and `protective_width_bps` to the same validator, with the rules in the Contract version 2 table).
Raw types are checked before typed decoding: a missing field, `null`, a boolean, a string, an object, an array or an unknown key is refused, and a timing field must parse as an integer (`1.5` and `1e3` are refused).
The dollar fields (`gross_budget_usd`, `hyperliquid_notional_cap_usd`, `okx_notional_cap_usd`, `unmatched_cap_usd`), each member's `contract_size`, `tick_step`, `lot_step` and `capital` must be finite and above zero; each venue cap and the unmatched cap must not exceed the gross budget (equality passes); the timing fields (`decision_cadence_ms`, `acceptance_deadline_ms`, `recovery_age_ms`, `max_hold_age_ms`, `retry_initial_ms`, `retry_max_ms`) must be in [1, 2147483647] (`pairMaxIntervalMs`); `retry_initial_ms` must not exceed `retry_max_ms`; and `recovery_age_ms` must be at least `acceptance_deadline_ms` plus the 120000 ms absence grace (`entryRecoveryGrace`).
Every time-plus-limit sum uses one overflow-checked addition and holds the operation on overflow.

**Cadence slots and quote events.** A slot is a `basket_rebalances` row reserved before collection (`reserved`); `collectPairSlot` (#1871, Pair quote collection and envelopes below) rechecks the reserved row and contract fingerprint after unlocked collection (`recheckPairSlot`) and freezes the envelope (`freezePairEnvelope`: cutoff, input fingerprint and evidence; `targets_frozen` with an empty decision).
The decision record is written once into `targets_json`: `targets_frozen` with an `enter` or `exit` record waits for its first action, `executing` has one, `held` keeps a policy hold, coordinator hold, invalid output or an interrupted unfrozen slot (a restart turns `reserved` or `ranking` into a durable hold; an undecided frozen slot is decided again from its saved envelope), `completed` reached its targets or served only as fresh evidence (`evidence_only`), and `superseded` was replaced (an older undecided slot, or a decided slot with no action).
Pair mode does not use the basket deadline hold or supersede rules; a new slot is reserved while actions are unresolved and never supersedes an `executing` slot.
A quote event is `(pair id, contract fingerprint, slot time, cutoff, input fingerprint)` (id `pq-<hash>`); the first leg uses its origin event, and each later member increase needs a newer event that member has not used.

**Decision entry point.** Only the coordinator calls the policy runner (`pairPolicyRunFn`, which is `runPairPolicyProcess` since #1875; tests replace it per coordinator through `basketCoordinator.pairPolicyRun`). `decidePairSlot` runs for the newest undecided slot when the pair is `flat` or `matched` and no coordinator exit is due (otherwise the slot ends `evidence_only`), sends a `pairPolicyRequest` (version 2, #1871) outside `mu` within the policy budget (`pairPolicyBudget`, #1873, Pair cadence, clocks and host order below).
It then discards the result if the slot or pair snapshot changed, decodes it strictly (`pairDecodePolicyResult`, Pair policy section below), validates it (`validatePairPolicyResult`: identity equal to the request, `enter` only when flat with opposite equal-size legal-lot targets, a matching orientation and positive price bounds, `exit` only when matched with zero targets; `invalid`, unreadable output, a runner error, a result past its useful decision window or a timeout hold the slot; a timeout also saves the `policy_infrastructure_timeout` marker) and freezes it (`freezePairTargets`, one changed-row-checked write with a read-back).
An entry whose full fill at its price bounds, converted with the verified envelope's price-currency conversions, would break a cap holds.
Since #1871 `decidePairSlot` first verifies the saved envelope (`verifyPairEnvelope`); damaged, changed or missing evidence holds the slot and no policy runs.

**Boundary rules** (`pairAgeWithin`, `pairSkewWithin`, `pairBeforeDeadline`, `pairReached`; issues #1863 and #1864 call these and add no second comparison). Caps pass at `basketWithin` (equality passes; an elective reduction during a breach passes when no reachable outcome is worse). Collateral, margin and fee reserve refuse only when the available amount is below the requirement. Quote age passes when the receipt is not after the reference and the age is at most the limit; skew passes at most the limit. A submission or venue acceptance is permitted only before the deadline (at equality nothing is sent and the action ends as proven never sent). Uncertain absence releases only after the deadline plus the grace, with fill history covering at least that time, complete matching history and no contrary evidence. Maximum hold age (from the first confirmed fill) and recovery age (from the saved recovery start) trigger at equality; a retry is permitted at its retry time.

**Planning.** Before each tranche the coordinator includes booked exposure, confirmed unapplied fills and the zero-to-remaining possible fill of every unresolved elective or protective request, and proves every independent no-fill/full-fill endpoint against gross notional, each venue's notional, unmatched aggregate base exposure at each member's own upper valuation, free collateral with fees and margin (the fee reserve uses the member binding taker rate, which is also the view `FeeRate`; it is a reserve, not a booked cost), and maintenance margin (Pair valuation view below, collateral rule); the upper valuation is the member's own best ask from its checked reference converted with its price currency, widened by every pending buy bound (a sell floor alone never proves an upper bound; with no own upper valuation every elective action holds).
Quantities are normalized base units with multiplier one; native quantity is computed once at the request.
Tranches are exact lot multiples; the first leg (the member with the coarser base lot) must fit alone, a hedge never exceeds confirmed exposure, exits alternate bounded tranches.
Dispatch needs both members' published views valid at the reader time (an increase: mark, conversions, capability and account; a reduction: the mark only); a frozen decision whose view fails at its first dispatch ends `held`.
An exit without an exit decision (a hold-age exit) bounds each tranche at the member's own reference depth: the native price of the level where the visible depth on the order side reaches the tranche quantity, else the deepest visible level, and the next step plans the rest.

**Lifecycle and recovery.** States are `flat`, `opening`, `matched`, `exiting`, `recovery` and `operator_held`.
An increase reserves its action, its linked entry intent (`decision_kind: quote`, `owner_mode: pair`) and its quote use in one transaction (`ReservePairAction`, which also checks the version-2 request against the selected envelope, #1871), and one later transaction commits both submitting transitions (`MarkPairActionSubmitting`) before transport.
Every unresolved action is queried in a stable order by its saved account-bound client id; an unknown elective action blocks later elective orders but never another member's protective query or reduction.
Evidence is validated (account, instrument, client, side, monotonic cumulative quantity and notional, no overfill, the incremental price within the saved bound, acceptance before the deadline, fee identity and version) and persisted before settlement; each confirmed delta is booked once at the price from the notional delta through the platform owner (Hyperliquid: the Hyperliquid result path; OKX: the shared perps owners), in the same partition save as the applied totals, quote consumption, intent and expected quantity.
An open order stays unresolved after an incremental booking.
A proven zero-fill first leg ends the entry; a rejected or failed hedge, an unexplained book change (stop, take-profit, liquidation, kill switch or operator close; growth or flip) or bad evidence freezes increases and opens recovery, which reduces every remaining leg toward zero with reduce-only protective actions and never reopens the closed side.
A proven terminal failed protective reduction retries under a new identity after capped exponential backoff (`retry_initial_ms` doubling to `retry_max_ms`); a query failure keeps the identity.
At recovery-age expiry the pair becomes `operator_held` with one CRITICAL alert while queries and protective reductions continue; it returns to `flat` only when both books are flat, every action is terminal and accounting is complete.
Pause, a quote outage and the persistence, daily-loss, notional and kill-switch holds stop increases only.
A latched kill switch keeps flattening priority: the kill-switch Hyperliquid and OKX closers (`pairKillSwitchHLCloser`, `pairKillSwitchOKXCloser`) and the circuit-close owners (routed per strategy through `pairCircuitHLCloserFor` and `pairCircuitOKXCloserFor`) always call the native close, and pair bookkeeping never gates it.
Since #1914 the coordinator is the only book writer for a pair member's emergency close: it books only confirmed venue fill records through the live settlement ledger, and every generic close owner skips pair members (Native emergency evidence and accounting (#1914) below).
The native close carries no pair client id, so a native action is never queried by its saved id; an unconfirmed outcome ends `held` (never `committed`) with a durable `native_fill_unknown` obligation when the member book is flat, when a later native close supersedes it, or at the acceptance deadline plus the absence grace.

**Pair settlement evidence (#1874).** Code: `scheduler/pair_settlement.go`. It applies to live-shaped members only (`pairLiveShaped`, native emergency actions included since #1914); a virtual research member keeps its valuation conversion and modeled fee, and its booking is unchanged.
An outcome may carry `fill_coverage` (`[]pairConfirmedFill`) and `fee_coverage` (`[]pairFeeFill`); records hold decimal strings, sort by the scoped fill id (partition, member, provider, account, instrument, client order id, venue fill id), and each record's `evidence_hash` is the SHA-256 of its canonical JSON with that field empty.
Execution records must be valid (printable id, venue time at least 0, quantity and native notional above 0, matching hash), unique, a superset of the accepted set with every retained record unchanged, and not above the cumulative totals; a violation holds and freezes like any inconsistent evidence. Outcome validation keeps coverage optional, so native closes never need it.
The execution set is complete when its exact decimal sums match `cum_qty` (`basketQtyEqual`, relative 1e-9) and `cum_notional` (relative 1e-9); the records stay the accounting authority.
The fee set is a subset of the execution set with equal execution fields; its version (`fee_evidence_version`) is the number of fee-covered fills; `fee_evidence_id` must equal `pairFeeEvidenceID(action)` (`pfe-` plus 32 hex of the saved order scope); each fee currency must be one of the member's frozen `fee_currencies`; a known zero fee is an explicit record and a negative amount is a rebate.
A higher version is accepted only as a strict superset with every retained record unchanged; the same version must be identical; a lower version that is a clean subset is stale and changes nothing.
Any other fee set (a dropped, changed or same-version different record, a changed identity, or new fills after finality) is a conflict: the accepted evidence stays, nothing is charged, increases freeze, a `fee_conflict` obligation stays and one durable CRITICAL alert (`fee_conflict|<action>`) is sent; authoritative reconciliation is required before release.
A malformed fee answer (bad record, hash, decimal, currency, duplicate, a version that is not its record count, or a record that matches no execution record) changes nothing and keeps a visible `fee_invalid` obligation without freezing; valid execution evidence in the same answer is still accepted, so confirmed unapplied exposure stays visible to risk and recovery.
`fee_final` advances only on an accepted or identical answer that is terminal, has complete execution coverage and covers every execution fill; a higher-version superset may set it; a stale, conflicting or incomplete claim cannot, and nothing clears it.
Quantity settles only with complete execution coverage, a nonempty accepted fee set and every needed conversion, and it books the fees of the newly booked fills with that quantity (`with_quantity`).
Missing coverage holds with `fill_coverage`, an empty fee set with `fee_unconfirmed`, a fill with no venue time and no `accepted_at_ms` with `conversion_unavailable`, a missing rate with `settlement_conversion`, and a legacy action (a fee version without saved records, or applied quantity without a ledger) with `legacy_settlement`; none of them replaces confirmed evidence with a modeled fee.
Partial fee coverage keeps `fee_incomplete` visible and the action unresolved; a later superset charges only the newly covered fees of already booked fills as a correction (`correction`), also while another quantity increment holds; fees of unbooked fills wait for their quantity.
A booked row whose fills are not all fee-covered keeps the confirmed amount with no modeled fee and `fee_source = userfills_pending`; the correction that covers every fill of that row sets `userfills`.
When an action ends (`committed` or `no_fill`), every per-action settlement obligation except `fee_conflict` and a `legacy_settlement` whose legacy cause remains is cleared before the save (`clearPairEndedObligations`), so a refused fee answer or a quantity hold never outlives its action.
A settlement pass does one kind of work: a quantity booking, or corrections of durable booked rows.
Each fill converts at its own time: the venue fill time, else the saved `accepted_at_ms` (the ledger records which).
The increase's saved request conversion is tried first (hash, member key, partition, currency, positive rate, observed by the fill, received by settlement, inclusive validity window around the fill).
Otherwise `pairFrozenEvidenceSource.SettlementConversion(ctx, member, currency, fillAtMs, settlementAtMs)` selects from the frozen schedule with `ObservedMs <= fillAtMs`, `FirstReceiptMs <= settlementAtMs` and `ValidFromMs <= fillAtMs <= ValidThroughMs`, preferring the latest receipt, then observation, then the smaller evidence id for an equal rate; tied different rates refuse. USD is the unit. The decision-time `Conversions(ctx, member, refMs)` is unchanged.
The coordinator captures the action, coverage, ledger, applied totals, finality and book under its lock, runs the bounded lookups unlocked (the `pairConversionLookup` hook is for controlled tests only), takes `settlementAtMs` from `pairNow` after the lookup (never earlier than the clock reading the lookup used), rechecks everything and discards a stale result without any change. A source error, timeout or refused record holds visibly; no cycle valuation or private fetch substitutes.
The incremental native price is checked against the saved bound before conversion; the USD price is the sum of separately converted native notionals over the booked quantity. An unbooked remainder (a booking below the increment) books at the saved fills' USD ratio with no fee, because each fee is charged once at its fill's first application.
The ledger (`settlement_json`) saves per fill the scoped id, hash, selected time and source, settlement time, quantity, native notional, price currency, complete conversion evidence, rate, USD notional and booking row, and per fee the record, its conversion, rate, signed USD value, mode and target row. Saved entries are never rewritten; a later fee uses only its own saved rate.
A correction targets each fill's own booking row (grouped per row, one `pairFeeFix` each with its row id and member stamp), never the action's last trade. One owning-partition transaction writes cash, the trade fee and its source (`userfills` once every fill booked in that row has a fee record, else `userfills_pending`), the closed position it ended, the linked intent, the applied totals, the coverage, the ledger and the applied version.
A fee correction on any close row (partial or full) changes the daily PnL by the same delta once and never changes the consecutive-loss count.
A provenance refusal, or a guard miss on an action row with a staged correction, undoes the staged cash, daily PnL, retained rows, intent, applied totals, booked rows and ledger (the status only if it is unchanged since staging), holds with `cost_provenance` and saves again at once; any other save error keeps the `needsDurable` retry, so a retry or restart never charges twice.
Paper members keep the modeled paper fees.

**Member cost bindings (#1887).** Each pair member has one immutable cost binding owned by its admission (`scheduler/pair_cost_policy.go`); no process-global map keyed by strategy id exists, and the legacy process policy (`bindFillCostPolicy`, `activeFillCostPolicy`) and the version-1 replay resolver, `fill_cost_policies` store and `verifyBasketReplayPolicy` are unchanged.

- **Transport declaration.** `pairVenue.ExecutionModel() string` declares the price algorithm: `virtual_ioc_adverse_max_v1` (`pairExecutionVirtualIOCAdverseMax`) only for a research admission's virtual transport (`Virtual()` true, `LiveShaped()` false), `venue_confirmed_v1` (`pairExecutionVenueConfirmed`) only for the controlled live-shaped transport; any other value is refused. `Virtual()` alone never proves the algorithm. The scripted research fixture declares `virtual_ioc_adverse_max_v1` and returns already priced IOC outcomes; #1880 supplies the algorithm itself.
- **Binding.** `loadPairResearchConfig` (shared by the research admission and the fixture) binds after the evidence source: for each contract member it checks that the transport provider equals the contract provider, that `AccountKey(partition)` equals the contract `account_key`, that the strategy is in the contract partition, and the declared model; the bindings are then frozen on the admission (`costs`, behind `costMu`), and a second load must rebuild identical bytes. `pairVenueFor` repeats the provider, account, partition and model check on every acquisition before dispatch or booking.
- **Canonical bytes and id.** `pairCostBinding` is exactly `{v: 2, contract_fingerprint, member_key (pairEvidenceKey), cost_policy_rates: {taker_fee_pct, slippage_pct (pairDecimal through pairDecimalFromFloat), fill_role, spread}, execution_model}`, written by `pairCanonicalJSON`; `PolicyID` = `pair-cost-` + the full lowercase SHA-256 of those bytes and is never part of them. Every member uses `ModelVersion` 1001 (`FillCostModelReplayCustomVersion`), rates equal to the legacy defaults included. Equal rates on different members have different ids; the contract bytes and fingerprint never include an id.
- **Durable evidence.** `pairProgress.CostEvidence` (`json.RawMessage`, JSON `cost_evidence`, inside `basket_contracts.progress_json`) holds the canonical `pairCostEvidence{v: 1, contract_fingerprint, members: [{member_id, binding, model_version, policy_id}]}` sorted by member key.
  A fresh run stages it at load and commits it in its first pair write (the slot reservation also writes it); until a commit succeeds `costDurable` is false, `stepPair` retries the commit and dispatches nothing, and every member policy resolution refuses.
  Load (`bindPairCostEvidence`) compares saved evidence with the rebuilt bindings by canonical bytes (missing, malformed, extra, changed rate, model, model version or id refuse with the saved state unchanged), even for a flat or zero-trade run. A run with saved pair state (a contract or progress row, a slot, an action, an expected quantity, a quote-event use, a pair-owned entry intent, or a member trade row or position) but no evidence is a pre-binding run that is refused and never stamped or converted. A run with no saved pair state, such as a resume after every first commit failed, stages the evidence the same way a fresh run does.
  The flat lifecycle reset keeps `CostEvidence`.
  Every pair write (`writePairTx`, `reserveBasketRebalanceRow`) checks the saved evidence before the contract upsert and refuses to add, remove or replace it.
- **Resolution.** `fillCostPolicyForStrategy(owner, id)`: a nil owner returns the legacy process policy; a released, unregistered, unbound or not yet durable owner, or an unknown member, refuses with `errPairCostOwnership`. A member book resolves through `StrategyState.Basket.Pair.admission` with its pair id, contract, partition and member checked (`strategyFillCostPolicy`); a pair member book (`pairCostPair`, set from `pair_id` and at the runtime link) that lost its link never falls back to the legacy policy, and pair-aware owners also check `sc.PairID` (`pairDispatchCostPolicy`).
- **Owners.** The checked binding is set as the book's cost scope before any cost or book change by `bookPairFill`, `executePerpsSignalWithLeverage`, `bookPerpsCloseWithFillFee`, `bookPerpsPartialCloseWithFillFee`, `applyPerpsScaleIn`, `executeHyperliquidResultDeferredOpen`, `executeOKXResult`, `closeVirtualPositionAtMark`, `closeVirtualPositionVerifiedFlat`, `applyHyperliquidCircuitCloseFill` (the model-only reconciliation included) and the manual queue; `paperStopBookPx(sc, s, side, px)` and the dynamic close regime check it before a trigger, regime or book change.
  Inside the scope the modeled fee is the member taker rate and paper slippage the member slippage on both venues; a confirmed IOC or venue fill books at its price with no second slippage, a confirmed fee of any sign (`pairConfirmedFee.Present`, #1874) replaces the modeled fee and keeps its order id, with the `userfills` source only when `Covered` is also set and `userfills_pending` otherwise, while ordinary callers keep the legacy positive-only selectors.
  A refusal changes nothing, logs CRITICAL and queues a fault that the next `stepPair` turns into a `cost_provenance` obligation, recovery with increases frozen and one CRITICAL alert; a confirmed live close that cannot be booked takes the same path and no later sync books it with another stamp.
- **Backstop.** `RecordTrade` stamps a pair member's execution or close row from the scope; outside a scope it records the row unchanged (version 0) and raises the fault, and a row whose stamp differs from the binding is recorded unchanged with the fault. Funding rows keep their type and the process stamp (#1888 owns pair funding). The ledger comparison still refuses model 1001.
- **Fee corrections.** Before any in-memory change the fee-only correction reads the target row from the owning partition file through `StateStore.PairTradeStamp` (also when it left the retained history) and requires exactly one row with the member stamp (and the same stamp on a retained copy); otherwise it holds with a `cost_provenance` obligation and increases frozen. The staged `pairFeeFix` carries `trade_row_id`, `cost_model_version` and `cost_policy_id`; `applyPairFeeFixTx` rechecks uniqueness and updates only that row id with that stamp, preserving it. A recheck failure (`errPairFeeFixProvenance` in `pairFeeFixError`) undoes the staged cash, daily PnL, retained row, intent and action changes, clears the pending fix, holds with the obligation and lets the partition save again; any other save error keeps the `needsDurable` retry. No correction row is created.

**Contracts for later children (#1880, #1881, #1888).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairCostBindings` | `func pairCostBindings(owner *pairResearchAdmission) map[string]fillCostPolicy` | fresh map keyed by member strategy id; each value has `ModelVersion` 1001, the full `PolicyID` and rates equal to `pairContractMember.CostPolicy`; nil for a nil, released, unbound or not yet durable owner; consumers refuse an absent or incomplete set |
| `fillCostPolicyForStrategy` | `func fillCostPolicyForStrategy(owner *pairResearchAdmission, strategyID string) (fillCostPolicy, error)` | nil owner = legacy; otherwise the durable member binding or an `errPairCostOwnership` error |
| `ExecutionModel` | `func (a *pairResearchAdmission) ExecutionModel(memberID string) (string, bool)` | the frozen model (`venue_confirmed_v1` or `virtual_ioc_adverse_max_v1`) once bound; #1880 declares `virtual_ioc_adverse_max_v1` through `pairVenue.ExecutionModel()` and reads rates from the contract member, which equal the binding |
| `pairCostBinding` | `V int "v"`, `ContractFingerprint string "contract_fingerprint"`, `MemberKey pairEvidenceKey "member_key"`, `CostPolicyRates pairCostPolicyRates "cost_policy_rates"`, `ExecutionModel string "execution_model"` | version `pairCostBindingVersion` 2; `pairCostPolicyRates` = `TakerFeePct pairDecimal "taker_fee_pct"`, `SlippagePct pairDecimal "slippage_pct"`, `FillRole string "fill_role"`, `Spread string "spread"` |
| `pairCostEvidence` | `V int "v"`, `ContractFingerprint string "contract_fingerprint"`, `Members []pairCostEvidenceMember "members"`; member = `MemberID string "member_id"`, `Binding pairCostBinding "binding"`, `ModelVersion int "model_version"`, `PolicyID string "policy_id"` | version `pairCostEvidenceVersion` 1, canonical bytes, members sorted by member key |
| `pairProgress.CostEvidence` | `json.RawMessage`, JSON `cost_evidence` | frozen at the first commit; later writes cannot change it; #1888 adds fields beside it and never rewrites it; `pairProgressVersion` stays 1 |
| `pairResearchManifest` | unchanged: `owner`, `run_id`, `pair_id`, `contract_fingerprint`, `pair_v`, `evidence_digest`, `uid` | carries no cost fields; the version-2 replay manifest (#1881) freezes `pairCostBindings` |

**Contracts for later children (#1874, for #1877, #1879 and later).**
Hyperliquid `tid` and OKX `billId` are the proposed venue fill ids; #1877 verifies and supplies them.

| Name | Go type or signature | JSON or column | Rule |
|---|---|---|---|
| `pairConfirmedFill` | `FillID string`, `VenueTimeMs int64`, `BaseQty string`, `NativeNotional string`, `EvidenceHash string` | `fill_id`, `venue_time_ms`, `base_qty`, `native_notional`, `evidence_hash` | canonical decimals above 0; `venue_time_ms` 0 means no venue time; `sealed()` sets the hash of the canonical JSON with `evidence_hash` empty |
| `pairFeeFill` | the five execution fields plus `FeeCurrency string`, `FeeAmount string` | adds `fee_currency`, `fee_amount` | signed canonical decimal (zero and rebates allowed); currency from the frozen member `fee_currencies`; execution fields equal the execution record |
| `basketVenueOutcome` | `FillCoverage []pairConfirmedFill`, `FeeCoverage []pairFeeFill` | `fill_coverage,omitempty`, `fee_coverage,omitempty` | `FeeEvidenceVersion` = `len(FeeCoverage)`, `FeeEvidenceID` = `pairFeeEvidenceID(action)`, `FeePresent` true; `FeeCurrency` and `FeeAmount` are optional summaries that must agree when `FeeCurrency` is set |
| `pairFeeEvidenceID` | `func pairFeeEvidenceID(a *basketAction) string` | | `pfe-` plus the first 32 hex of SHA-256 over partition, member, provider, account, instrument and client order id |
| `pairScopedFillID` | `func pairScopedFillID(a *basketAction, fillID string) string` | | the order scope, a `|`, then the venue fill id; sort and ledger key |
| `basketAction` | `AcceptedAtMs int64`, `FillCoverageJSON string`, `FeeCoverageJSON string`, `FeeFinal bool`, `SettlementJSON string` | `accepted_at_ms INTEGER 0`, `fill_coverage_json TEXT ''`, `fee_coverage_json TEXT ''`, `fee_final INTEGER 0`, `settlement_json TEXT ''` | guarded as in Storage Ownership; empty means unknown |
| `pairLedger` | `V int`, `Fills []pairLedgerFill`, `Fees []pairLedgerFee` | `v` (1), `fills`, `fees` | append-only per action |
| `pairLedgerFill` | `ScopedFillID`, `FillID`, `EvidenceHash`, `SelectedTimeMs`, `TimeSource` (`venue_time` or `accepted_at`), `SettlementAtMs`, `BaseQty`, `NativeNotional`, `PriceCurrency`, `Conversion *pairConversionEvidence`, `RateUSD`, `NotionalUSD float64`, `Booking pairLedgerRow` | snake_case of each name | `Conversion` is nil only for USD |
| `pairLedgerFee` | `ScopedFillID`, `FillID`, `EvidenceHash`, `SelectedTimeMs`, `SettlementAtMs`, `FeeCurrency`, `FeeAmount`, `Conversion`, `RateUSD`, `FeeUSD float64`, `Mode` (`with_quantity` or `correction`), `Target pairLedgerRow` | snake_case | `FeeUSD` is signed and never recomputed |
| `pairLedgerRow` | `TradeTsMs int64`, `TradeTs string`, `OrderID string`, `IsClose bool`, `ClosedPositionID string` | `trade_ts_ms`, `trade_ts`, `order_id`, `is_close`, `closed_position_id` | the saved booking row a fee correction targets |
| `pairConfirmedFee` | `Present bool`, `Covered bool`, `Currency string`, `Amount float64`, `Conversion pairConversionEvidence`, `EvidenceID string`, `EvidenceVersion int64`, `DeltaUSD float64`, `Parts []pairLedgerFee` | in-process only | `pairBookFill.Fee`; `Present` makes a zero or negative `DeltaUSD` the booked fee; `Covered` (fail-closed, set only when every fill booked in that row has a fee record) selects `userfills`, else the row saves `userfills_pending`; `Parts` keeps each fill's conversion when one booking spans rates |
| `withPairConfirmedFeeScope` | `func withPairConfirmedFeeScope(s *StrategyState, fee pairConfirmedFee, fn func()) error` | | the explicit-fee seam for native member closes (#1914); refuses a non-finite delta; `strategyExecutionFee` and `strategyExecutionFeeSource` read it; ordinary callers never set it |
| `SettlementConversion` | `func (s *pairFrozenEvidenceSource) SettlementConversion(ctx context.Context, m pairContractMember, currency string, fillAtMs, settlementAtMs int64) (pairConversionEvidence, error)` | | the two-time selection above; callers check the result with `pairCheckSettlementConversion`; it is not part of `pairEvidenceSource` |
| obligations | `fill_coverage`, `fee_unconfirmed`, `fee_invalid`, `fee_incomplete`, `fee_conflict`, `settlement_conversion`, `conversion_unavailable`, `legacy_settlement` | `progress_json` obligations | each counts as incomplete accounting (`pairSettlementObligationKind`), keeps a held action queried, and is recovery evidence under `pairRecoveryObligation` (the startup contract-change and wrong-file refusals) until its action ends |

**Pair funding (#1888).** Each research pair member is charged its own venue's funding by one pair lane under the coordinator and the partition save (`scheduler/pair_funding*.go`, Python mirror `shared_tools/pair_funding.py`).
The legacy Hyperliquid owner skips every pair member (`pairFundingOwned(sc)` = `isPairMember(sc)` in `syncPaperFundingEligibility`, `planPaperFunding`, `runPaperFundingAccounting` and `paperFundingCoins`), so a pair member has one funding owner and eligibility sync never creates, deletes or charges its state; `paperFundingEligible` and the quote owner are unchanged.
The controlled live-shaped fixture has no funding lane: a venue-confirmed charge never receives a modeled paper charge, and a fixture admission that supplies a funding source is refused because attributed live funding ingestion has no owner in this build.

- **Source.** `pairResearchDeps.Funding pairFundingSource` is required for a research admission (nil or any other type refuses before any write). Only `*pairFrozenFundingSource` (`newPairFrozenFundingSource(doc, wantDigest)`: digest, strict canonical decoding and every record checked) and `*pairRecordedFundingSource` (`newPairRecordedFundingSource(sourceID, anchorMs)` plus `Append(batch []byte) error`) are accepted. The lane never fetches: it calls `Collect` outside `mu` under one `collection_timeout_ms` context and rechecks the pair snapshot after relocking; a collection error alerts once and changes nothing.
- **Binding.** `pairFundingBinding{v: 1, contract_fingerprint, members (full keys sorted), source_kind, source_id, digest (frozen document digest, else ""), anchor_ms (recorded anchor, else 0), rules_digest, rules}` is built at admission (`bindFunding`), written as `funding_binding` into `pair-research.json` before any database write and into `pairProgress.FundingBinding` (JSON `funding_binding`, beside `cost_evidence`, frozen at the first commit; `pairProgressVersion` stays 1).
  Resume compares canonical bytes before any write and refuses a missing manifest binding (a pre-funding run needs a new run), a changed frozen digest, source, anchor, member set or venue rule table (`rules_digest`) with the files unchanged; storage load (`bindPairFundingBinding`) refuses saved pair state without a binding; every pair write (`writePairTx`, `reserveBasketRebalanceRow`) refuses to add, remove or replace it (`checkSavedPairFundingBindingTx`).
  A recorded source binds only its identity and anchor; its accepted batches are saved in `pair_funding_chain`, and resume reads them through an immutable snapshot open (`openPairFundingSnapshot`, before any writable open) and requires them to be a byte-identical prefix of the source's chain; a later batch may only append.
- **Coverage and visibility.** Funding windows are `(from_ms, through_ms]`. A coverage record proves that every settlement in each `proven` interval is among the rate records; a last returned record, an empty page, an exhausted request allowance or a timeout never proves anything alone. A read at cutoff C uses only records with `visible_ms <= C`; a coverage record whose proven interval contains a rate first visible after the coverage record is refused, and a recorded batch cannot add a rate inside earlier proven coverage. Held exposure (from the inventory history up to `min(C, inventory_through)`) that no proven interval covers is a `funding_gap` obligation per member; it never becomes a proven zero.
- **Venue rules** (`pairFundingRules`; a rule change needs a new rule version and refuses resume through `rules_digest`; an unknown rule leaves the event incomplete):

| `rule_version` | Venue | Rate | Price | Formula | Same-time order | Evidence |
|---|---|---|---|---|---|---|
| `hyperliquid_hourly_oracle_v1` | Hyperliquid, USDC | `fundingHistory.fundingRate` | `oracle` | `native_amount = -(signed_base_qty × native_mark × rate)` | inventory strictly before `event_ms`; assessment window 0 | Hyperliquid docs: "The funding rate on Hyperliquid is paid every hour"; "the funding payment at the end of the interval is position_size * oracle_price * funding_rate ... the spot oracle price is used ... not the mark price"; positive rate: "the long position will pay the short position" |
| `okx_swap_realized_mark_v1` | OKX, USDT | `funding-rate-history.realizedRate` ("Actual funding rate") | `mark` | the same, with `signed_base_qty` = contracts × contract size | inventory strictly before `event_ms`; an inventory change whose possible time lies in `[event_ms, event_ms + 60000)` holds the event (`assessment_window_fill`) | OKX help: "Funding fee = Position value × Funding rate", "Position value = Number of contracts × Contract size × Contract multiplier × Mark price", "When the funding rate is positive, traders with long positions pay", default every 8 hours "unless specified otherwise (i.e. every 1, 2 or 4 hours)", "The actual fee assessment may take up to a minute" |

  Neither venue states a rounding rule, so the exact decimal product is kept; no schedule is assumed (each settlement record is one event); another venue's rate never replaces a member's own.
- **Event-time inventory.** The perps book owner records every pair member book change: `mutatePaperPerpsBookCoins` calls `pairInventoryTrack` (via `StrategyState.pairFunding`, set at the runtime link) before the legacy helper, and the append-only `pair_funding_inventory` history keeps each signed base delta with `event_ms`, `event_latest_ms` (equal for an exact time), `visible_ms`, origin (`coordinator`, `native`, `unexplained`), action and fill identity, the position identity after the change, post quantity and contract size.
  A row whose post quantity keeps the sign of the book before the change keeps the earlier position identity; a row that opens or flips takes the new one, and a flat row has none.
  Coordinator settlements pass `pairFillContext` (`withPairFillContext`) from the outcome's `fills` (`basketVenueOutcome.Fills []pairVenueFill{FillID "fill_id", BaseQty "base_qty", EventMs "event_ms", VisibleMs "visible_ms"}`: unique printable ids, finite positive quantities summing to `cum_qty`, event at or after `accepted_at_ms`, visibility at or before `observed_at_ms`, earlier fills never changed or dropped); without fills the delta's time is the range `[accepted_at_ms or submitted_at_ms, observed_at_ms]`.
  Native changes take the book clock; a book change outside the helper is reconciled each step and in each saving write as an `unexplained` range since the last observation.
  Funding at T uses rows with `event_latest_ms < T`; a row with `event_ms < T <= event_latest_ms` makes the event `unresolvable` (`fill_time_unknown`).
  Inventory is complete through `min(last observation, for each unresolved member action: coverage_through_ms when everything confirmed is applied, else submitted_at_ms - 1)`; an event needs it through `max(T, T + window - 1)` (`inventory_incomplete` otherwise).
  A history row earlier than an applied event opens a durable `funding_integrity` obligation with recovery, frozen increases and a CRITICAL alert; the applied charge is never rewritten, and the obligation stays saved but does not hold lifecycle completion.
  Load refuses a history that does not sum to the saved book.
- **Settlement** (`stepPairFunding`, after reconciliation and exception detection, before lifecycle checks; no new charge while `SaveBlocked`, `needsDurable` or a staged application exists).
  An event settles once it is visible, its assessment window has passed, inventory is complete, the inventory before T is nonzero (proven zero writes nothing, or turns an earlier incomplete row `void`), the rule is known and serves the member's provider and settlement currency, coverage proves `(max(exposure stretch start, previous rate), T]`, one price of the rule's kind at T is visible, and a conversion from the bound frozen evidence document is valid at T and observed and received by the cutoff (`FundingConversion`: `conversion_invalid_at_event` and `conversion_unavailable_by_cutoff` are separate reasons; `pairEligibleAt` is not used).
  The USD amount is converted once; cash changes by it and one trade row is recorded (`Side`/`TradeType` `funding`, `PnLGross`, `RealizedPnL` = USD amount, `Timestamp` = T, `PositionID` = the identity of the last inventory row before T, never the position open at settle time, `ExchangeOrderID` `pair_funding:<rule_version>:<event_id>`, the process cost stamp); `RecordTradeResult`, daily PnL, consecutive losses and the circuit breaker are untouched.
  The member book save commits the cash, the row, the applied event, the inventory history and the obligation changes in one transaction; a failed save keeps `needsDurable` and retries the same staged application, and a restart re-evaluates from the saved state, so each event changes cash once.
  Missing evidence writes a durable `incomplete` row with its reason and a `funding_incomplete` obligation; lifecycle completion, `pairStartupErrors` (changed contract), ordinary-load refusal and removed-pair refusal treat funding obligations as open accounting.
  Funding gaps never reuse the `funding_venue_gaps` venue-absent rule.
  An event that no future evidence can settle, because the immutable inventory history is ambiguous at T (`fill_time_unknown`), changes inside the assessment window (`assessment_window_fill`) or names no position identity before T (`position_identity_unknown`), is saved `unresolvable` once inventory is complete through its need (or, since #1927, once its assessment interval intersects a native funding hold interval (open `[FromMs, infinity)`, bounded `[FromMs, ThroughMs]`, reason `native_decision_unproven`, one alert per hold)): no charge, no obligation, one CRITICAL alert, and the detail gives the possible signed base position range and, when the rule and price are known, the native charge range.
  An unresolvable event never holds lifecycle completion; holds that only wait for evidence (coverage, price, conversion, inventory, rule) keep their obligation.
  Source hashes are remembered per rate identity, keyed by the hashed event time and rate, and seeded at load from every applied event after its saved source hash is rechecked (a mismatch refuses resume), so a cycle with no new records computes no source hash for an applied event.
  Each inventory row keeps its parsed quantity, the member keeps its running total, and held exposure is built by one sorted sweep and cached until a row is appended (also a late earlier row), so a cycle with no new rows parses no earlier row and rebuilds nothing.
- **Negative cash.** `ValidateStateWithPairOwner(state, strategies, owner)` keeps negative cash only for members of a valid research admission's contract that has a funding binding and member cost bindings (paper partition, matching `pair_id`); `ValidateState` is the nil-owner form, so a `pair_id` alone grants nothing, ordinary OKX strategies keep their clamp and legacy Hyperliquid eligibility keeps its exemption. A pair host runs it after `LoadBaskets` with the admission.
- **OKX range reader.** `OKXExchangeAdapter.get_funding_history_range(symbol, start_ms, end_ms, page_limit=100, max_requests=16, deadline_ms=30000) -> dict` maps a coin or `<COIN>-USDT-SWAP` to the native instrument, validates integer arguments, `page_limit` in [1, 400] and `max_requests` in [1, 64] (both refused before any request), and calls `publicGetPublicFundingRateHistory({"instId", "after": cursor, "limit"})` directly (no market load, no ccxt filter).
  The cursor starts at `end_ms + 1` and moves to the oldest `fundingTime` of each page (OKX docs: `after` returns "records earlier than the requested fundingTime", `limit` "The maximum is 400", history "up to three months"); a row at or after the cursor, a conflicting duplicate, a non-text `realizedRate`, a foreign instrument or more rows than the limit stops the read.
  It returns `{provider, inst_id, rate_field: "realizedRate", requested: {from_ms, through_ms}, events: [{event_ms, inst_id, realized_rate, funding_rate, method, formula_type}] (raw text), proven, gaps, complete, requests, stop_reason, error}`: proven runs from `start_ms` (when a record at or before it was reached) or the oldest record minus one to the newest record; a short page (`history_exhausted`), the request bound (`request_limit`), the deadline, a transport (`transport_error`, ccxt network errors) or venue failure (`venue_error`) or malformed page leaves a gap; the interval after the newest record is always `after_last_record`; any error clears events and proves nothing.
  It differs from the Hyperliquid adapter's lenient same-name list method.
  `get_funding_history` is unchanged.
  `pair_funding.okx_range_records(key, result, visible_ms, provenance, evidence_id)` turns a result into rate records and one coverage record for a frozen document; Go `pairFundingFromHyperliquidCoverage` does the same for `hlFundingRecordsCoverage`, proving only through its last returned record.

**Research funding oracle (#1934).** An isolated paper run may opt in to a research Hyperliquid funding price with `scenario.funding_policy`; without a policy every legacy rule, serializer, digest and wire version stays byte-identical.
`scheduler/testdata/pair_funding_legacy/` pins the legacy rules digest, binding v1, document v1, batch v1 and event v2 bytes captured from the base serializers, and `TestBasketPairFundingPythonLegacyCanonicalGoldens` also settles, saves and reloads a legacy event through the real coordinator and compares its `event_json` byte for byte.

- **Policy.** `pairFundingPolicyV1{version, member_rules, window_ms, max_print_offset_ms, prepare_lead_ms, from_ms, drain_through_ms}` (JSON names as written) has every field required and these exact values: version 1, `window_ms` 5000, `max_print_offset_ms` 60000, `prepare_lead_ms` 30000, bounds equal to the collection bounds, the Hyperliquid member selecting `hyperliquid_hourly_oracle_receipt_research_v1` and the OKX member `okx_swap_realized_mark_v1`.
  A null policy, a null, float, string, missing or extra field, another member, another rule or other bounds is refused before the state directory is claimed (`validatePairPaperSpec`), and binding (`pairFundingValidatePolicyForContract`) checks the same mapping against the admitted contract.
  `policy_digest` is the SHA-256 of the canonical policy (`pairFundingPolicyDigest`, Python `policy_digest`).
  The timing values are fixed contract values without a mainnet measurement; another timing tuple needs another rule version.
- **Selection.** `pairFundingResearchRules` (Python `RESEARCH_RULES`) is a separate catalog; `pairFundingRules`, `pairFundingRulesDigest`, the legacy provider lookup and Python `RULES` are unchanged.
  With a policy, `pairFundingSelectedRule` returns the member's selected rule; a rate with another rule is kept as evidence and reaches `rule_unsupported` at the existing rule step.
  The research rule keeps the legacy formula and currency, price kind `oracle_receipt_research`, assessment window 0 and the single evidence statement "research receipt-time observation; timing bounds do not establish venue settlement equality or oracle-update age".
- **Hour mapping.** `T = event_ms - event_ms mod 3600000`; an event uses an observation for `T` only when `0 <= event_ms - T <= 60000` and `from_ms <= T <= drain_through_ms` (`pairFundingResearchTarget`), so a print 60001 ms or more after the hour stays `price_missing`.
  Event id, event time, position identity, inventory, conversion validity and coverage stay at the real print time; no rate is rounded or moved.
- **Observation.** An observation is valid only when `T <= request_ms <= receipt_ms <= D` with `D = min(T + 5000, drain_through_ms)`, `at_ms = visible_ms = receipt_ms` (the raw local receipt; there is no venue timestamp and no sample field), the price is a finite positive canonical decimal and the provenance is fixed.
  `evidence_id` is `hyperliquid-oracle:<instrument>:<T>:<receipt_ms>` and `attempt_id` is `hyperliquid-oracle-attempt:` plus the SHA-256 of canonical `{key, target_event_ms, policy_digest}`.
  Exactly one distinct eligible record hash for the full key and `T` visible by the cutoff selects it (`pairFundingObservationFor`); two distinct hashes, also at an equal price or another receipt time, are `conflicting_evidence`, and no nearest, earliest or best value is ever chosen.
  A price may arrive before its rate; an observation batch never proves or extends rate coverage.
- **After application.** Each policy step rechecks every applied and staged research event against the visible observations, also without a new rate and after a restart (`pairFundingAppliedRef` carries the policy digest, observation hash and target); a second hash opens the durable `funding_integrity` hold with recovery, frozen increases and a CRITICAL alert in that step and changes no prior cash, trade or event.
  `appliedVenueRules` and `pendingVenueRules` (keyed by full key and venue event id) refuse a second rule's charge for one venue event before staging and again in `stagePairFunding`: the collision adds the integrity hold, records no trade and changes no cash, and the database unique index stays the last guard.
  `pairFundingValidateSelectedEvent` checks a version 3 event against the admitted policy, the selected observation (by record hash and every copied field), the rate and the arithmetic at staging, inside the save transaction, at load and in the export checks; a legacy source keeps the legacy event checks only.
- **Reader.** `HyperliquidExchangeAdapter.public_oracle_observation(symbol)` needs the public-only mainnet adapter, makes one `metaAndAssetCtxs` read, requires exactly one universe entry for the symbol and the context of the same response, and returns the canonical `oraclePx` text, refusing a missing, duplicate, malformed, bool, float, NaN, infinite, padded, zero or negative value; it never uses cached metadata, the mark price, a private call or an exchange timestamp.
  `fetch_pair_history.py --kind=funding_oracle --provider=hyperliquid --instrument --member-key-json --target-event-ms --not-before-ms --deadline-ms --funding-policy-json --policy-digest` builds the adapter first, then waits in sleeps of at most 50 ms until `time.time_ns() // 1000000 >= T`, refuses a backward clock and a pass of `D`, sets `request_ms` right before and `receipt_ms` right after the one read, refuses times outside `[T, D]`, and prints `{ok, kind, provider, instrument, host, observation, request_ms, receipt_ms}`.
  `--kind=funding` takes `--funding-policy-json`, `--policy-digest` and `--rule-version` together or not at all and then seals a version 2 batch with no observation; the legacy funding and candle arguments are unchanged and refuse the new flags.
  `pairHistoryHLOracleProbeArgv`, `pairHistoryHLResearchFundingProbeArgv` and `pairHistoryOKXResearchFundingProbeArgv` are built at package init from a canonical probe policy and run in both probe vectors (`probeCheckScripts` and `paperProbes`).

| Wire object | Version | Fields (all required) |
|---|---|---|
| `pairFundingPolicyV1` | `version` 1 | `version`, `member_rules`, `window_ms`, `max_print_offset_ms`, `prepare_lead_ms`, `from_ms`, `drain_through_ms` |
| `pairFundingOracleObservation` | `v` 2 | `v`, `key`, `evidence_id`, `attempt_id`, `policy_digest`, `price_kind` (`oracle_receipt_research`), `target_event_ms`, `request_ms`, `receipt_ms`, `at_ms`, `visible_ms`, `time_basis` (`local_request_receipt`), `price`, `provenance` (`hyperliquid_info_metaAndAssetCtxs_oraclePx_receipt_research_v1@https://api.hyperliquid.xyz`), `record_hash` |
| `pairFundingDocumentV2` | `v` 2 | `v`, `source_id`, `policy`, `policy_digest`, `rates`, `prices`, `observations`, `coverage`; observations sort by key, target, receipt, evidence id, record hash |
| `pairFundingBatchV2` | `v` 2 | `v`, `source_id`, `seq`, `prev_hash`, `policy_digest`, `rates`, `prices`, `observations`, `coverage`, `batch_hash`; an oracle batch holds one observation and nothing else, a rate batch no observation; one source accepts one version family |
| `pairFundingEventV3` | `v` 3 | every v2 event field plus `policy_digest`, `observation_evidence_id`, `observation_record_hash`, `observation_target_event_ms`, `observation_request_ms`, `observation_receipt_ms`, `observation_time_basis`; `price_hash` = observation hash, `native_mark` = observation price; only the research rule writes it, OKX stays v2 |
| `pairFundingBindingV2` | `v` 2 | every v1 binding field plus `policy` and `policy_digest`; `rules` and `rules_digest` cover the sorted selected rule table |

  The in-memory `pairFunding*Value` envelopes (document, batch, event, binding) carry the legacy projection and the authoritative research value, refuse `json.Marshal` with an error that names the type, and are written only through `pairFunding*ValueBytes` or `pairFundingBindingCanonical`; decoders dispatch on the integer `v` and refuse unknown versions.

**Contracts for later children (#1881, #1882).**

| Name | Go type or signature (JSON) | Rule |
|---|---|---|
| `pairFundingSource` | `Kind() string`, `SourceID() string`, `Collect(ctx, pairFundingRequest) (pairFundingCollected, error)`, `NextVisibleMs(key pairEvidenceKey, afterMs int64) (int64, bool)` | `pairFundingRequest{Key pairEvidenceKey; FromMs, ThroughMs, CutoffMs int64}` returns records with `FromMs < event/at <= ThroughMs` (coverage overlapping the window) and `visible_ms <= CutoffMs`, sorted; `NextVisibleMs` is the earliest record visibility after `afterMs` for a schedule wake |
| `pairFundingRate` | `v int "v"` (1), `key pairEvidenceKey "key"`, `event_id string "event_id"` (`<provider>:<instrument>:<event_ms>`), `rule_version string`, `event_ms int64`, `visible_ms int64` (≥ event), `rate pairDecimal`, `provenance string`, `record_hash string` | source hash (`pairFundingSourceHash`, Python `source_hash`) = SHA-256 of canonical `{key, event_id, rule_version, event_ms, rate}` decides equal duplicate or conflict; `record_hash` covers the whole record |
| `pairFundingPrice` | `v`, `key`, `evidence_id`, `price_kind` (`mark`, `oracle`), `at_ms`, `visible_ms` (≥ at), `price pairDecimal` (> 0), `provenance`, `record_hash` | the event uses one price of the rule's kind with `at_ms = event_ms` |
| `pairFundingCoverageRecord` | `v`, `key`, `evidence_id`, `requested_from_ms`, `requested_through_ms`, `proven []pairFundingInterval{from_ms, through_ms}`, `gaps []pairCoverageGap{from_ms, through_ms, reason}`, `visible_ms`, `provenance`, `record_hash` | intervals sorted, disjoint, inside the request; proven and gaps never overlap |
| `pairFundingDocument` | `v` (1), `source_id`, `rates`, `prices`, `coverage` | canonical bytes (`pairFundingDocumentBytes`, Python `document_bytes`) sort by key then time; one identity per document; at most 65536 records per kind and 16 MiB |
| `pairFundingBatch` | `v` (1), `source_id`, `seq` (from 1), `prev_hash` ("" for seq 1), `rates`, `prices`, `coverage`, `batch_hash` | append-only chain; `Append` refuses a gap in `seq`, a wrong `prev_hash`, a conflicting identity, a repeated evidence id and a rate inside earlier proven coverage |
| `pairFundingEvent` (v2) | `v`, `key`, `event_id`, `rule_version`, `event_ms`, `visible_ms` (latest input visibility), `signed_base_qty`, `native_mark`, `rate`, `currency`, `native_amount`, `usd_amount` (all `pairDecimal`), `conversion pairConversionEvidence`, `coverage pairFundingCoverage`, `source_hash`, `price_hash`, `evidence_hash` | written once when applied and immutable; `pairDecodeFundingEvent` and Python `decode_event` recheck identity, rule, currency, amounts, conversion validity at T, coverage and hash |
| `pairFundingCoverage` | `v` (1), `key`, `requested_from_ms`, `requested_through_ms` (= event), `proven`, `gaps` (empty when applied), `inventory_through_ms`, `source_kind`, `source_id`, `cutoff_ms` | named `pairFundingCoverage` because #1871's quote `pairCoverage` already exists |
| `fundingSource` | `func (a *pairResearchAdmission) fundingSource() pairFundingSource` | the bound source beside `evidenceSource()` and `quoteSource()`; nil for the live-shaped fixture |
| `pairProgress.FundingBinding` | `json.RawMessage`, JSON `funding_binding` | frozen at the first commit beside `cost_evidence`; the manifest carries the same bytes |
| `ValidateStateWithPairOwner` | `func ValidateStateWithPairOwner(state *AppState, strategies []StrategyConfig, owner *pairResearchAdmission)` | the replay host (#1881) and paper host (#1882) call it after `LoadBaskets` instead of `ValidateState` |
| Funding rows | `TradeTypeFunding`, `Side` `funding`, `ExchangeOrderID` `pair_funding:<rule_version>:<event_id>`, process cost stamp | excluded from loss counters; a replay exports them from `trades` and the applied rows from `pair_funding_events` |

**Management-only members.** A member check runs with `--pair-member-json='{"pair_id","member_id","provider","instrument","mode":"management_only"}'` (Hyperliquid batch slot `pair_member`): native closes, ATR, indicators and protection run, no entry evaluator and no open-strategy close fallback run, and the output must echo the request exactly or Go records a script error and pair entry holds while trusted protection keeps its authority. Go drops every position-increasing member signal on the Hyperliquid step (after the check and again after admission), on both OKX routes and on batched results. `check_okx.py` accepts the request and, with `--management-stdin`, reads frozen management input (version 1: at least 30 candles and a mark) without constructing an exchange client (`runOKXPairManagementCheck`); its basket-member refusal is unchanged. Robinhood and TopStep accept the flag for probe shape and refuse it at run time.

**Interfaces for issues #1863 and #1864.** The venue seam is `pairVenue`: `basketVenue` plus `Virtual()`, `ExecutionModel()` (Member cost bindings); since #1876 the venue seam carries no valuation, and every valuation comes from the coordinator's published view (Pair valuation view below). `Submit` and `Query` take the saved action (purpose, base and native quantity, price bound, acceptance deadline, account, native instrument, client id); an outcome carries `cum_qty`, `cum_notional`, `accepted_at_ms`, `terminal`, coverage, optional per-fill `fills` (Pair funding below), the echoed identity, fee evidence (`fee_present`, `fee_currency`, signed `fee_amount`, `fee_evidence_id`, `fee_evidence_version`) and the #1874 `fill_coverage` and `fee_coverage` records, and `never_sent`, `absent` and `open` outcomes. A `Submit` at or after the saved deadline must send nothing and answer `never_sent`.
Since #1877 `pairVenue` also has `Position(ctx, member, nowMs)` for live-shaped venues (Pair bounds, transport and the position barrier below).

**Contract version 2 (#1869).** `pairContractVersion` is 2 and every field below is part of the contract fingerprint.
A saved version-1 pair contract is refused with an explicit unsupported-version error and is never converted: on resume the run manifest's `pair_v` (absent means 1) is checked before its fingerprint, and storage load (`parseBasketContract`) checks `pair_v` first and then refuses duplicate keys, invalid UTF-8 and unknown fields in saved pair-contract bytes (saved bytes need not be canonical).
Version-1 basket contracts keep their bytes and fingerprints.
Reload refuses any change, a pair member's leverage included, and startup refuses a changed contract while a position, an unapplied fill, a funding gap or recovery evidence remains.

| Owner | Go field | Go type | JSON | Rule |
|---|---|---|---|---|
| `PairConfig`, `pairContractBody` | `BookNeed` | `pairBookNeed{Depth int; WindowMs int64; Capacity int}` | `book_need` {`depth`, `window_ms`, `capacity`} | depth [1, 20], window_ms [1, 900000], capacity [1, 4096], through `pairBookNeed.validate()` (the same bounds `contractBookNeed(window, capacity, depth).validate()` applies; only sealed members build that feed need, #1871) |
| `PairMemberConfig`, `pairContractMember` | `Source` | `string` | `source` | Hyperliquid `sealed_feed`, `archive` or `public_snapshot`; OKX `public_snapshot` |
| `PairMemberConfig`, `pairContractMember` | `ModeledDeliveryMs` | `int64` | `modeled_delivery_ms` | required; `archive` [1, 2147483647], every other source explicit 0 |
| `PairMemberConfig`, `pairContractMember` | `CostPolicy` | `fillCostPolicy` | `cost_policy` | raw keys exactly `taker_fee_pct`, `slippage_pct`, `fill_role`, `spread` (`model_version` and `policy_id` refused even at zero); `validateFillCostRates`; the contract freezes `rates()` (it serializes `model_version: 0` and no `policy_id`) and never binds the process-global cost policy; #1887 resolves and uses it (Member cost bindings) |
| `pairContractMember` | `Leverage` | `float64` | `leverage` | the member's `EffectiveExchangeLeverage`; no raw config field |
| `pairOperationalLimits` | `QuoteAgeMs`, `CollectionTimeoutMs`, `ReceiveSkewMs` | `int64` | `quote_age_ms`, `collection_timeout_ms`, `receive_skew_ms` | [1, 2147483647]; timeout and skew not above the quote age |
| `pairOperationalLimits` | `EnvelopeMaxBytes` | `int` | `envelope_max_bytes` | [1, 4194304] |
| `pairOperationalLimits` | `ProtectiveWidthBps` | `float64` | `protective_width_bps` | finite, above 0, below 10000 |
| `pairInstrumentMeta` | `PriceCurrency` | `string` | `price_currency` | Hyperliquid `USDC`; OKX `USDT` |
| `pairInstrumentMeta` | `PriceRule` | `string` | `price_rule` | Hyperliquid `hyperliquid_perps_v1`; OKX `fixed_tick_v1` |
| `pairInstrumentMeta` | `SzDecimals` | `int` | `sz_decimals` | Hyperliquid [0, 12] (`hlLotMetadataMaxSzDecimals`, zero valid); OKX explicit 0 |
| `pairInstrumentMeta` | `TickStepText`, `LotStepText` | `string` | `tick_step_text`, `lot_step_text` | OKX: positive plain decimals equal, as exact rationals, to the shortest decimal of `tick_step` and `lot_step`; Hyperliquid: empty |
| `pairInstrumentMeta` | `FeeCurrencies` | `[]string` | `fee_currencies` | nonempty, unique, upper-case; the contract serializes a sorted copy |

Raw kinds are checked at every nested level before typed decoding (`pairRawPairErrors`, called by `pairResearchRawPair` and `pairConfigErrors`).
Required fields refuse missing and `null`; numbers refuse booleans and strings and must be finite; integers also refuse fractions and exponents; booleans (`linear` included) and strings need their JSON kinds; `fee_currencies` needs an array of strings; unknown keys are refused at the pair, member, meta, cost-policy, book and limits levels.

**Currencies and units (#1869).** An OKX member needs `quote_currency`, `settlement_currency` and `price_currency` `USDT` and the instrument `<UNDERLYING>-USDT-SWAP`: every supported OKX order, close, query, candle, funding and balance route selects USDT swaps, and the legacy position reader keeps only the bare coin while the strict pair read (#1877) keys the exact instrument.
A Hyperliquid member is quoted, priced and settled in `USDC`.
No stablecoin is assumed equal to USD: the frozen evidence carries a conversion schedule for every required currency of a member (`pairRequiredConversionCurrencies`: the settlement, price and fee currencies), `USD` itself needs no record and a `USD` conversion record is refused.
Account collateral must be the settlement currency.
Hyperliquid native quantity is base units with contract size 1; OKX native quantity is contracts; base = contracts × contract_size, computed once in Go at the transport and position boundary (#1871, #1877 and the position and accounting children apply this rule).
Python scripts emit native contracts and native contract notional with an explicit unit and contract identity.

**Evidence records (#1869).** `scheduler/pair_evidence.go` owns these shapes; `shared_tools/pair_evidence.py` mirrors them (`SCHEMAS`).
Decimal fields use `pairDecimal` (a `string` type: `MarshalJSON` normalizes, `UnmarshalJSON` accepts canonical form only).

| Go type | JSON fields, all required | Rules |
|---|---|---|
| `pairEvidenceKey` | `partition`, `provider`, `account_key`, `instrument`, `member_id` (strings) | canonical risk partition (`live`, `paper`, `paper:<id>`), a pair provider, nonempty trimmed account, instrument and member; `pairMemberKey.evidenceKey()` and `pairEvidenceKey.memberKey()` convert and validate |
| `pairEvidenceStamp` (embedded anonymously, flattened) | `v` int, `key`, `evidence_id`, `evidence_hash`, `observed_ms`, `first_receipt_ms`, `valid_from_ms`, `valid_through_ms` (int64), `provenance` | `v` 2; id 1..128 and provenance 1..256 printable ASCII; no negative time; observed not after first receipt; `valid_from_ms` not after `valid_through_ms`; `evidence_hash` is the record hash |
| `pairCapabilityEvidence` | stamp, `meta` (`pairWireMeta`), `metadata_generation` (uint64) | provenance is the provider derivation id; generation at least 1, never decreasing within a key, and equal generations carry equal `meta` |
| `pairWireMeta` | `native_unit`, `base_asset`, `contract_size`*, `quote_currency`, `settlement_currency`, `price_currency`, `tick_step`*, `lot_step`*, `min_qty`*, `min_notional`*, `trading_status`, `linear` (bool), `max_leverage`*, `price_rule`, `sz_decimals` (int), `tick_step_text`, `lot_step_text`, `fee_currencies` (sorted []string); * = `pairDecimal` | public instrument rules only, per the derivation table below |
| `pairConversionEvidence` | stamp, `currency`, `rate_usd`* | upper-case currency other than `USD`; rate above zero |
| `pairAccountEvidence` | stamp, `position_mode`, `margin_mode`, `leverage`*, `collateral_currency`, `equity`*, `free_collateral`*, `margin_used`* | leverage above zero; free collateral and margin used not negative |
| `pairEvidenceDocument` | `v` (2), `capabilities`, `accounts`, `conversions` | one immutable research input; SHA-256 of its canonical bytes is the document digest |

**Field derivation table (#1869).** The provenance of a capability record is its versioned derivation id.
Go (`pairValidateWireMeta`, `pairExpectedWireMeta`, `pairCapabilityMatches`) and Python (`validate_wire_meta`, `hyperliquid_wire_meta`, `okx_wire_meta`, `expected_wire_meta`, `capability_matches`) apply the same table; the contract side is projected through the shortest decimal of each float and compared field for field.

| `pairWireMeta` field | Hyperliquid `hyperliquid_info_meta_v1`: POST `/info` `{"type":"meta"}`, entry resolved through `Info.name_to_asset` | OKX `okx_public_instrument_v1`: GET `/api/v5/public/instruments?instType=SWAP&instId=<instrument>` through the ccxt public client | Compared |
|---|---|---|---|
| `native_unit` | constant `base` | constant `contract` | yes |
| `base_asset` | entry `name` (the instrument) | `ctValCcy`, which must equal the `instFamily` base | yes |
| `contract_size` | constant `1` | `ctVal` | yes |
| `quote_currency` | constant `USDC` | `instFamily` quote | yes (OKX must be `USDT`) |
| `settlement_currency` | constant `USDC` | `settleCcy` | yes (OKX must be `USDT`) |
| `price_currency` | constant `USDC` | the quote currency | yes (OKX must be `USDT`) |
| `tick_step` | 10^-max(0, 6 − szDecimals): the finest increment; legal prices follow `price_rule` (at most 5 significant figures) | `tickSz` | yes |
| `lot_step` | 10^-szDecimals | `lotSz` | yes |
| `min_qty` | 10^-szDecimals (one lot) | `minSz` | yes |
| `min_notional` | named constant `10` (`MIN_ORDER_NOTIONAL_USD`, `pairHyperliquidMinNotional`) | constant `0` (no OKX minimum-notional rule) | yes |
| `trading_status` | `delisted` when `isDelisted` is true, else `live` | `live` when `state` is `live`, else the raw `state` | yes (the contract requires `live`) |
| `linear` | constant `true` | `ctType` is `linear` | yes |
| `max_leverage` | entry `maxLeverage` (a positive integer) | `lever` | yes |
| `price_rule` | constant `hyperliquid_perps_v1` | constant `fixed_tick_v1` | yes |
| `sz_decimals` | entry `szDecimals`: an integer equal to `asset_to_sz_decimals[asset]` for a main-universe perpetual; never the `_sz_decimals` fallback | constant `0` | yes |
| `tick_step_text`, `lot_step_text` | empty | `tickSz`, `lotSz` normalized | yes |
| `fee_currencies` | [`USDC`] | [`settleCcy`] | yes |
| contract-only | `position_mode`, `margin_mode` and `leverage` are checked against account evidence; `initial_margin_rate` and `maintenance_margin_rate` are conservative model rates with no public field (1/maxLeverage is not a finite decimal for every Hyperliquid leverage; OKX tiers come from a separate position-tiers endpoint) and are never compared | same | no |

**Canonical writer (#1869).** Go `pairCanonicalJSON(v interface{}) ([]byte, error)`; Python `canonical_bytes(obj) -> bytes`, `evidence_hash(record) -> str`, `seal_record`, `document_bytes` and `normalize_decimal`.
Output is compact UTF-8 with every object's keys sorted byte-wise (Go struct fields included); integers keep full precision (`-0` is written `0`); a non-integer number or a binary float is refused, so schema decimals are strings; booleans stay booleans.
Strings escape `"`, `\`, `\n`, `\r` and `\t` in short form, other control characters as `\u00xx`, `<`, `>` and `&` as `\u003c`, `\u003e` and `\u0026`, and U+2028 and U+2029 as `\u2028` and `\u2029`; all other characters stay raw UTF-8.
A canonical decimal matches `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$` and is never `-0`; writers normalize schema decimals from plain or exponent input with redundant zeros, and only schema decimals are normalized (identifiers, provenance and step text keep their content).
A record hash is the lowercase SHA-256 of the canonical record with `evidence_hash` present as `""`; verification repeats it.
`pairEvidenceDocumentBytes` sorts fee currencies, capabilities and accounts by key (partition, provider, account_key, instrument, member_id, each compared byte-wise), first receipt, observation and evidence id, and conversions by key, currency, first receipt, observation and evidence id; other consumer schemas order their own arrays before calling the writer.
The strict readers (Go `pairStrictParse` with `pairDecodeEvidenceDocument`; Python `loads_strict`, `decode_record` and `decode_document`) refuse invalid UTF-8, duplicate keys (escaped spellings included), lone surrogate escapes, trailing values, wrong kinds, missing or unknown fields, unsupported versions and any bytes that differ from the canonical re-encoding, before typed decoding loses information.
Macro and basket hash formats are unchanged.

**Evidence source (#1869).** `pairEvidenceSource` has `Capability(ctx context.Context, m pairContractMember, refMs int64) (pairCapabilityEvidence, error)`, `Conversions(ctx, m, refMs) ([]pairConversionEvidence, error)` (one record per required currency, sorted by currency) and `Account(ctx, m, refMs) (pairAccountEvidence, error)`.
`pairResearchDeps.Evidence` is required; research admission accepts only `*pairFrozenEvidenceSource` from `newPairFrozenEvidenceSource(doc []byte, wantDigest string)`, which checks the digest, canonical bytes, versions, structure and every record hash.
`bind` runs inside `loadPairResearchConfig`, before the transport checks and before `checkStateDir` can write, and checks every record against the contract: partition and member identity, capability equality, account position mode and margin mode equal to the contract meta, account leverage equal to the frozen `leverage` as an exact rational, collateral equal to the settlement currency, and conversion currencies inside the required set; each member needs a capability, an account and a per-currency conversion schedule.
Because admission checks every record, a frozen document changes at run time only through time eligibility (gaps, expiry, future receipt); #1871 exercises its increase hold that way, and a mismatched-record hold needs another `pairEvidenceSource` implementation.
A read at `refMs` selects among records with `observed_ms`, `first_receipt_ms` and `valid_from_ms` at or before `refMs` and `valid_through_ms` at or after it, preferring the latest first receipt and then the latest observation; a tie with different content is an error (an equal-content tie returns the lowest evidence id); conversions are selected per currency; nothing substitutes current data, unit conversion or a private read; a cancelled context returns its error; records are returned as copies.
`Digest()` is saved as the manifest `evidence_digest`, and a resume needs the same digest before any write; the input document stays outside the run directory.
`(*pairResearchAdmission).evidenceSource()` exposes the bound source; consumers validate with `pairCapabilityMatches`, `pairAccountMatches`, `pairConversionMatches`, `pairRequiredConversionCurrencies` and `pairValidateWireMeta`.
#1871 collects outside `mu`, rechecks the pair snapshot and persists increase holds through pair progress; an evidence outage must not become a whole-step return before recovery queries and protective reductions, which keep their authority.

**Public capability reader (#1869).** `shared_scripts/fetch_pair_evidence.py --provider=hyperliquid|okx --instrument=<native> [--probe-only]` prints `{"ok":true,"provider","instrument","provenance","observed_ms","meta"}` and exits 0, or prints `{"ok":false,"error"}` and exits 1; `--probe-only` parses its arguments and exits before it loads an adapter or reaches the network.
It clears the Hyperliquid and OKX signing and account variables, loads each adapter by file path under its own module name and constructs it with `public_only=True` (Hyperliquid: no wallet, no metadata cache read or write, a failed read raises instead of the SDK fallback, strict `public_instrument_rules` through `name_to_asset`; OKX: no keys, `publicGetPublicInstruments`); egress pinning is unchanged.
Go `pairReadPublicCapability(ctx context.Context, key pairEvidenceKey, prev *pairCapabilityEvidence, validForMs int64) (pairCapabilityEvidence, error)` needs a caller deadline and spawns the reader through `spawnPythonProcessWithEnv` with those variables cleared and `PYTHONDONTWRITEBYTECODE=1`.
Its stamp: `observed_ms` is the reader clock right after the venue response (neither source carries a venue time), `first_receipt_ms` is the Go receive time and must not be earlier, `valid_from_ms` is `first_receipt_ms`, `valid_through_ms` is `first_receipt_ms + validForMs`, `provenance` is the derivation id, `evidence_id` is `<provenance>:<first_receipt_ms>:<first 16 hex of the SHA-256 of the canonical meta>`, and `metadata_generation` is 1 or the earlier record's generation, plus one when `meta` changed.
It checks the derivation table but not the contract; callers use `pairCapabilityMatches`.
It cannot read account position mode, selected leverage, margin mode or collateral; research account and conversion schedules come from the frozen document, and the controlled account reader is #1877.
`probeCheckScripts` registers `--provider=hyperliquid --instrument=BTC --probe-only` once when any `hyperliquid` `perps` strategy is configured and `--provider=okx --instrument=BTC-USDT-SWAP --probe-only` once for any OKX `perps` swap; startup and `go-trader probe` both reach it, and a pair-bearing ordinary config still fails load before dispatch.

**Bound rounding (#1869).** Go `pairRoundBound(m pairContractMember, cap pairCapabilityEvidence, side string, bound float64) (float64, error)`; Python `round_bound(member, capability, side, bound)` for #1875, where `member` is the contract member JSON.
It first requires `pairCapabilityMatches` (record hash, provider, account, instrument and member identity, derivation, metadata equal to the contract); partition and time eligibility belong to the evidence source and its consumer.
The input enters through its shortest decimal and every step uses exact rationals: `hyperliquid_perps_v1` calls the existing `quotePriceRule.roundQuotePx` with the verified `sz_decimals` (including its second precision step), and `fixed_tick_v1` uses the `tick_step_text` grid; a buy cap rounds down and a sell floor rounds up.
It refuses an invalid side, an unsupported rule, a nonpositive or non-finite input or output, and a result whose float projection, parsed again through its shortest decimal, is not exactly the rounded value, not legal on the venue grid or outside the directed bound; it never widens a bound.

### Pair quote collection and envelopes (#1871)

Issue #1871 adds the depth sources, the bounded collector, the frozen two-venue envelope, its verifiers in Go and Python, the collection step and the version-2 policy and action requests.
It activates no pair in `main.go`: the research hosts are #1881 and #1882, wakes, per-read snapshots, clocks and the host order are #1873 (Pair cadence, clocks and host order below), the valuation view is #1876, rounded-bound and crossing enforcement is #1877 and required reduction references are #1879.

**Source contracts.** One compatibility table (Go `pairSourceSpecs`, `pairSourceSpecFor(provider, source)`, `pairSourceContractFor`, `pairSourceContractMap`; Python `pair_inputs.SOURCE_SPECS`) maps each frozen member source to its contract:

| Provider, source | Source contract | Time semantics | Identity semantics | Coverage |
|---|---|---|---|---|
| Hyperliquid `sealed_feed` | `hyperliquid_ws_l2Book` | `source_event_ms+first_receipt_ms/1635-v1` | `sha256-lp(host,namespace,coin,event_ms,levels)/1635-v1` | the sealed window |
| Hyperliquid `archive` | `hyperliquid_archive_l2_v1` | `archiver_receipt_ms+modeled_delivery_ms/1871-v1` | as sealed | the archive window on the modeled delivery timeline |
| Hyperliquid `public_snapshot` | `hyperliquid_public_l2_snapshot_v1` | `hyperliquid_l2_time_ms+reader_receipt_ms/1871-v1` | `sha256(canonical_payload)/1871-v1` | one point |
| OKX `public_snapshot` | `okx_public_books_snapshot_v1` | `okx_books_ts_ms+reader_receipt_ms/1871-v1` | `sha256(canonical_payload)/1871-v1` | one point |

`pairBookNeed.validate()` checks the numeric bounds of the Contract version 2 table without building a feed need.
`pairFeedBookNeeds(rt *basketRuntime) map[feedObservationKey]feedBookNeed` calls `contractBookNeed(window_ms, capacity, depth)` only for sealed Hyperliquid members, keyed by `l2BookKeyFor(instrument)`; a host that seals a pair snapshot merges these entries through `feedRequirements.addBook` (the existing `addBookNeed` union and conflict list), so `feedBookNeed`, the shared feed and ordinary feed derivation stay Hyperliquid-only and unchanged.
Admission also refuses a configuration whose worst-case envelope (`pairEnvelopeWorstCaseBytes`: every payload, level, gap and evidence record at its bound, payloads counted as base64) exceeds `envelope_max_bytes`, because no slot could ever freeze; a sealed or archive member with capacity 4096 never fits, while a public member's payload does not depend on capacity.

**Quote source.** `type pairQuoteSource interface { Sources() map[string]string; Recorded() bool; Collect(ctx context.Context, req pairCollectRequest) ([]pairObservation, error); Reference(ctx context.Context, req pairReferenceRequest) (pairObservation, error) }`.
`pairCollectRequest{Partition RiskPartition; PairID string; ContractFingerprint string; SlotMs, StartMs, DeadlineMs int64; Members []pairContractMember; BookNeed pairBookNeed; SourceContracts map[string]string; Snapshot *marketSnapshot; SnapshotID string; Generation uint64}` and `pairReferenceRequest{Partition RiskPartition; PairID string; ContractFingerprint string; Member pairContractMember; Side string; BookNeed pairBookNeed; SourceContract string; StartMs, DeadlineMs int64; Snapshot *marketSnapshot; SnapshotID string; Generation uint64}`.
`Sources()` returns a copy of member id to frozen source label; `Recorded()` true means saved reads that keep their original provenance and receipts (never a network fallback or a new receipt stamp).
`pairResearchDeps.Quotes` is required: `newPairResearchAdmission` builds the contract and runs `pairQuoteSourceErrors(contract, quotes)` before the transport checks and before `checkStateDir` can write; a nil source, a missing, extra or provider-incompatible member, or a label other than the frozen source is refused.
`(*pairResearchAdmission).quoteSource()` exposes it beside `evidenceSource()`.
The production source is `newPairQuoteCollector(p PairConfig, limits pairOperationalLimits, pairQuoteCollectorOptions{Egress *EgressConfig; ArchiveDirs map[string]string})`: sealed members read the captured snapshot in process (`marketSnapshot.l2BookPayload` with the contract need), public members spawn the public reader, archive members spawn the archive reader on an absolute prepared archive directory; `Collect` runs the members concurrently and cancels the siblings when one fails.
Sources return native levels only; `Reference` returns both sides and `Side` (`buy` or `sell`) names the side that must reach the full depth (the other needs one level).

**Payloads.** `Payload` is bounded verified source bytes (standard padded base64 in JSON) and `PayloadHash` is the lowercase SHA-256 of those bytes; a verifier re-derives every observation field from the payload, so a matching hash alone proves nothing.
Hyperliquid public: the #1869 canonical bytes of `{"request":{"coin","type":"l2Book"},"response":{"coin","levels":[[{"n","px","sz"}],[...]],"time"}}`, each side cut to the requested depth.
OKX public: the canonical bytes of `{"request":{"instId","sz":"<depth>"},"response":{"code":"0","data":[{"asks","bids","ts"}],"msg"}}` from `GET /api/v5/market/books` with raw decimal text (docs/research/1871-okx-public-books-source.md).
Sealed: the canonical `marketL2BookPayload` for the member key and contract need.
Archive: the `backtest/book_replay.py` `payload_from_series` section for the source-native cutoff and as-of, with `time_semantics` `archiver_receipt_ns_floor_ms/1635-v1` and an empty snapshot id.

**Observation** (`pairObservation`, V=2; field list in the PR #1871 contracts section) derivation (Go `pairDerive`, Python `pair_inputs.derive`, one rule set):
- Every decimal is a plain positive decimal of at most 32 characters and 18 fractional digits, normalized exactly (`canonicalBookDecimal`); bids strictly descend, asks strictly ascend, the best bid is below the best ask and each side reaches the depth; the observation keeps the first `depth` levels.
- Venue time is required for all four contracts (a null venue time is refused); it is at most 250 ms after and at most 5000 ms before the receipt.
- Public: `FirstReceiptMs` is the reader clock right after the response (inside the Go launch window), `SourceCutoffMs` equals it, snapshot id empty and generation 0, and coverage is one point: `from_ms = through_ms = first_receipt_ms`, `required_depth = depth`, `required_window_ms = 0`, `required_capacity = 1`, prefix complete; it never claims websocket history, and a consumer that needs history refuses it.
- Sealed and archive: the latest observation in the window is the quote; every marker in the window and every receipt gap above 10000 ms (window start, between observations, to the cutoff) is a coverage gap, and coverage is complete only with a complete prefix and no gap; `provenance` is `<contract>@<owner_instance>`.
- Archive: the reader asks for the source-native cutoff and as-of `start - modeled_delivery_ms` (the replay owner's strict as-of boundary is kept), then receipt, coverage start and end and gaps add the modeled delivery once with checked arithmetic; venue times and `source_cutoff_ms` stay source-native, and a recorded replay never adds it again.
- A sealed observation carries the captured snapshot id and generation; a public or archive observation carries none.
- Normalization happens in the builder after evidence selection: base quantity = native quantity × the capability `contract_size` (exact), price stays native, `native_unit`, `contract_size` and `price_currency` come from the capability, and `normalization_id` is `<native_unit>*<contract_size>@<capability evidence_id>`.

**Envelope.** `pairQuoteEnvelope{V int "v"; Partition string "partition"; PairID string "pair_id"; ContractFingerprint string "contract_fingerprint"; SlotMs int64 "slot_ms"; StartMs int64 "start_ms"; DeadlineMs int64 "deadline_ms"; CutoffMs int64 "cutoff_ms"; SnapshotID string "snapshot_id"; Generation uint64 "generation"; Members []pairEnvelopeMember "members"}` with `pairEnvelopeMember{Key pairEvidenceKey "key"; Observation pairObservation "observation"; Capability pairCapabilityEvidence "capability"; Conversions []pairConversionEvidence "conversions"; Account pairAccountEvidence "account"}`, V=2.
`buildPairEnvelope(in pairEnvelopeInputs) (pairEnvelope, string)` (empty reason is success) normalizes, sorts members by full key and conversions by currency then evidence id, writes the #1869 canonical bytes and runs the verifier on them; `InputFP` is the lowercase SHA-256 of the complete canonical envelope bytes.
`verifyPairEnvelope(rt *basketRuntime, rb *basketRebalance) (pairQuoteEnvelope, string)` never refetches: it checks the saved bytes against `input_fingerprint`, the strict canonical decoding (`pairDecodeQuoteEnvelope`; the strict readers gained a nullable integer kind and a bytes kind in both languages), version, partition, pair, contract, slot, cutoff, snapshot identity and every rule below.
Rules: size within `envelope_max_bytes`; `slot_ms <= start_ms <= cutoff_ms < deadline_ms = start_ms + collection_timeout_ms`; exactly the contract members with their full keys; each observation verified against its payload with complete coverage; each receipt at or before the cutoff and within `quote_age_ms`; receipts within `receive_skew_ms` of each other; one capability (`pairCapabilityMatches`), one account (`pairAccountMatches`, account leverage equal to the frozen leverage) and exactly the required conversions (`pairConversionMatches`), each bound to the member key and partition and eligible at the cutoff (`pairEligibleAt`); no midpoint, last price or other-venue substitute.
`pairBoundCrosses(env pairQuoteEnvelope, memberID, side string, bound float64) bool`: a buy bound at or above the best native ask, or a sell bound at or below the best native bid, crosses (equality crosses); #1877 enforces it with the rounded bound.
Python `shared_tools/pair_inputs.py` (standard library and `pair_evidence.py` only) exposes `verify_envelope(request: dict) -> dict`, which takes exactly the version-2 policy request, re-canonicalizes the embedded envelope, requires its SHA-256 to equal `input_fingerprint`, checks the request's `source_contract` against the table and applies the same rules from the request's `book_need`, `limits` and members (`source`, `source_contract`, `modeled_delivery_ms`, `leverage`, `meta`); it also exposes `check_envelope(expect, raw)`, `derive` and `bound_crosses`, and `pair_evidence.py` gained `account_matches`, `conversion_matches`, `required_conversion_currencies` and `eligible_at`.

**Collection step.** `stepPair` runs reconciliation, exception detection, lifecycle checks and the single protective call first, then `collectPairSlot`, then `decidePendingPairSlots`, then elective execution; a collection failure never returns from the step before queries, settlement or protection.
`collectPairSlot` runs for `flat`, `opening` and `matched` pairs and skips while `SaveBlocked` or `needsDurable`.
It reads the coordinator clock (`basketCoordinator.pairClock`, `tradingNow` by default, never earlier than the step clock), takes slot = floor(now / cadence) × cadence and reserves only an absent slot (`errBasketDeadlineTaken` means no collection; no backfill); a nil or mismatched quote source or a missing evidence source holds with a logged reason and no reservation.
Under `mu` it captures immutable copies of the members, contract fingerprint, book need and source contracts; outside `mu` it reads the seal for a sealed member through `in.PairSnapshotFor(rt, start)` (#1873; never `in.Snapshot` or `in.SnapshotFor`), then collects and reads the evidence under one context of `collection_timeout_ms` measured as a duration (a historical logical time never becomes an operating-system deadline); evidence is read at the final cutoff, which is the clock when the reads return.
After reacquiring `mu` it holds the slot when the pair records changed, the read failed, the context ended, the wall budget was used up, the cutoff reached the logical deadline, the contract changed, `recheckPairSlot` failed, the seal was missing or invalid, or the host feed generation moved during the read; otherwise it builds and freezes through `StateStore.FreezePairEnvelope` in the pair's partition file before any policy runs.
A hold is written through pair progress; if that save fails the slot stays reserved on disk, `needsDurable` is set and no slot is collected until the retry saves, and a restart turns the reservation into the existing durable hold.
The step refreshes `in.Now` and the runtime clock after every unlocked operation (valuations, queries, collection, the policy run and each submit) before any time-sensitive decision.

**Use of saved evidence.** `decidePairSlot` verifies the saved envelope before the policy; damaged, changed or missing evidence holds the slot and the policy never runs.
An increase verifies the selected quote event's envelope (`pairBindIncrease`; the event may be newer than the origin decision), requires that member's receipt within `quote_age_ms` of the dispatch time (a first dispatch that fails ends the decision held), and copies the member conversions, `capability.evidence_hash` and the envelope fingerprint into the immutable request.

**Version-2 requests.** `pairPolicyRequestVersion` and policy results are 2; the request adds `BookNeed pairBookNeed "book_need"` and each `pairPolicyMember` adds `CostPolicy fillCostPolicy "cost_policy"`, `Source string "source"`, `SourceContract string "source_contract"`, `ModeledDeliveryMs int64 "modeled_delivery_ms"` and `Leverage float64 "leverage"`; `envelope` is the saved bytes unchanged.
`pairActionRequestVersion` is 2 and adds `ContractFingerprint string "contract_fingerprint"`, `Conversions []pairConversionEvidence "conversions,omitempty"`, `CapabilityHash string "capability_hash,omitempty"`, `EvidenceHash string "evidence_hash,omitempty"`, `Reference *pairObservation "reference,omitempty"` and `NativeBooked bool "native_booked,omitempty"`.
`StateStore.ReservePairAction` validates every new request (`pairValidateActionRequest`) and, for an increase, rechecks inside the reservation transaction that the selected slot row, its envelope bytes and fingerprint, the quote event, the capability hash and the conversions agree (`checkPairIncreaseBindingTx`), then saves the request, entry intent and quote use atomically:

| Request kind | Required | May be absent |
|---|---|---|
| Increase (`pair_entry`: first leg or hedge) | `quote_event_id`, `contract_fingerprint`, `conversions`, `capability_hash`, `evidence_hash`, all checked in the reservation transaction against the selected event's saved envelope | none |
| Coordinator reduction (`pair_exit`, `pair_protective`, protective retry) | `contract_fingerprint`, `reduce_only` | evidence fields (refused when present); `reference` optional and verified when present (source, payload, identity, depth on the order side, receipt within `quote_age_ms`) |
| Native-booked (`pair_kill_switch`, `pair_operator`) | `contract_fingerprint`; `native_booked: true` only with a matching recovery note and `reduce_only` | all other evidence fields, the quote event and the price bound |

A saved version-1 request keeps its bytes (the immutable `request_json` trigger) and stays usable for query, settlement and native recovery; it is never reserved again or resent as version-2 authority.

**Readers.** `shared_scripts/fetch_pair_quotes.py --provider=hyperliquid|okx --source=public_snapshot|archive --instrument=<native> --depth=<1..20> [--window-ms --capacity --archive-dir --cutoff-ms --as-of-ms] [--probe-only]` prints `{"ok":true,"provider","source","instrument",...,"payload"}` (public adds `host`, `provenance`, `request_ms`, `receipt_ms`) and exits 0, or prints `{"ok":false,"error"}` and exits 1; `--probe-only` validates the arguments (archive flags are required for the archive source and refused for public) and exits before any adapter or archive load.
It refuses nonempty `HYPERLIQUID_SECRET_KEY`, `HYPERLIQUID_ACCOUNT_ADDRESS`, `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_PASSPHRASE`, `HYPERLIQUID_TESTNET` and `OKX_SANDBOX`; Go passes them as empty overrides with `PYTHONDONTWRITEBYTECODE=1`.
Public reads use `HyperliquidExchangeAdapter(public_only=True).public_l2_snapshot(symbol, depth)` (strict `public_instrument_rules` resolution, then `Info.l2_snapshot`) and `OKXExchangeAdapter(public_only=True).public_order_book(inst_id, depth)` (`publicGetMarketBooks`, no market load, no float conversion); each adapter's `public_host()` must be the mainnet host, which goes into the provenance.
The archive reader loads `backtest/pair_archive_book.py`: `prepare_archive(archive_dir, out_dir, coins, days, manifest)` runs `book_archive.import_window` (which verifies each object through `archive_cache.verified_object`) and writes `import_report.json` with the day-file digests and per-file `day_bounds` (row count, open rows, first and last row time, last eviction time, first coverage time), and `archive_payload(import_dir, coin, cutoff_ms, as_of_ms, need)` opens only the tail, the last file with coverage before the as-of time and each file whose bounds can hold a row present in the window at the as-of time, verifies each opened file against its digest and bounds, and returns the same `payload_from_series` section as a full load; archive preparation and host orchestration stay outside collection.
A row received in the window can sit in a later day file because a day file holds the rows evicted on that day, so the reader selects by bounds and never by calendar day.
`probeCheckScripts` registers `pairQuoteHLPublicProbeArgv` and `pairQuoteHLArchiveProbeArgv` when any Hyperliquid `perps` strategy is configured and `pairQuoteOKXPublicProbeArgv` for any OKX `perps` swap, each carrying every runtime flag; startup and `go-trader probe` reach the same owner.

**Launcher and egress.** `spawnPythonProcessWithEnvLimited` and `spawnPythonProcessRawLimited` take opt-in `pythonOutputLimits{Stdout, Stderr int}`; an overflow cancels the process group and returns `*pythonOutputOverflowError` after the existing bounded pipe cleanup, and the zero value keeps every existing caller unbounded.
The quote readers cap each stream at `envelope_max_bytes` and enter through `acquirePythonSlot(ctx)`, the shared `pythonSemaphore` with a cancellable wait, holding the permit until the spawn and its cleanup return.
A public read first calls `pairPublicEgressReady(ctx, cfg)`: `establishProcessEgress(ctx, cfg, true)`, then, when a proxy is configured or required or the runtime is active, it requires under the runtime lock an active runtime with an attestation and no lost attestation; there is no direct fallback, and a stderr overflow is a child failure.

**Storage growth.** Each collected slot writes its envelope (up to `envelope_max_bytes`, at most 4 MiB) once into `basket_rebalances.evidence_json` and, since #1875, one decision record of at most 98304 bytes (`pairTargetsMaxBytes`) into `targets_json`.
`prunePair` prunes memory only, so a research run's state file grows by at most `envelope_max_bytes` plus 98304 bytes per cadence slot, and runs are sized as duration ÷ `decision_cadence_ms` × (`envelope_max_bytes` + 98304) (research-only; no production loader hosts pairs).

### Pair policy `cross_exchange_perp_arbitrage` (#1875)

Issue #1875 adds the first research pair policy beside the test-only fixture policy, its evaluator process and its parameter validation; it stays research-only, and no ordinary route, deployed configuration, default, promotion or live transport admits a pair.
#1877 adds authoritative Go bound rounding, post-round validation, saved version-2 bound evidence and dispatch crossing checks (Pair bounds, transport and the position barrier below); #1878 replaced the temporary runner table with the canonical pair accessor and registered the policy as production-refused (Registration below); replay latency, polling and recording stay with #1881 and #1882.

**Parameters** (Go `pairPolicyCrossExchangePerpArbitrage`, `pairPolicyParamKeys`, `pairPolicyParamValidators`, `pairCrossExchangeParamErrors`; Python `cross_exchange_perp_arbitrage.param_errors`).
`params` needs exactly `entry_threshold_bps`, `exit_threshold_bps`, `adverse_band_bps` and `funding_allowance_bps` as finite JSON numbers; there are no defaults, and missing keys, unknown keys, `null`, booleans, strings, arrays, objects and numbers that overflow a float64 are refused.
Rules: `0 <= exit_threshold_bps < entry_threshold_bps <= 10000`, `0 <= adverse_band_bps < 10000` and `funding_allowance_bps >= 0`.
Go checks the rules on the float64 values it later sends, and Python checks the exact decimal of those same tokens, so both languages accept the same values.
`pairRawPairErrors` JSON-decodes and trims the raw `policy` string, selects `pairPolicyParamValidators[name]` and runs it on the raw `params` (an absent `params` reports all four keys missing); it runs in `pairResearchRawPair`, which research admission and the controlled fixture call before the first typed decode, evidence bind or state-directory write, and in `pairConfigErrors`.
Errors are sorted and deduplicated; a policy without a validator (the test-only fixture policy) keeps the typed unknown-key check (`pairParamErrors`), and the add-strategy command refuses the name through `productionRefusedStrategies` before its pair-token check (#1878).

**Evaluator and runner.**
`shared_strategies/open/cross_exchange_perp_arbitrage.py` exposes `evaluate_pair(request: dict) -> dict`, where `request` is the strict tree from `pair_evidence.loads_strict` (numbers stay `JSONNumber` tokens).
It imports only the standard library and loads `shared_tools/pair_evidence.py` and `shared_tools/pair_inputs.py` by file path under those names (a module of the same name loaded from another file is refused); it reads no clock, configuration, state, network or adapter, and no single-symbol registry, shim or composer import loads it.
`shared_scripts/run_pair_policy.py [--probe-only]` sets `sys.dont_write_bytecode`, loads the canonical `shared_strategies/open/pair_registry.py` by file path under `go_trader_pair_registry` (never `registry.py`, NumPy, pandas, adapters, configuration or state), checks membership against `pair_policies()` and resolves the evaluator only through `get_pair_evaluator(name)`; it reads one request from stdin and prints exactly one finite JSON object (sorted keys, no NaN) with exit 0 for `enter`, `exit`, `hold` and `invalid`.
Non-strict request bytes (invalid UTF-8, duplicate keys, a trailing value), a non-object, a request version other than 2 and an unknown policy are `invalid`; an unexpected exception, a missing or failing canonical registry or evaluator import, or an output failure writes a traceback to stderr and exits 1, which never authorizes a decision (the runner catches no exception from `get_pair_evaluator` or `evaluate_pair`).
`--probe-only` loads the canonical registry and every evaluator in `pair_policies()` order without reading stdin and prints `{"ok":true,"probe":true,"policies":[...]}`; a failed load exits 1.
Go `runPairPolicyProcess(ctx context.Context, stdin []byte, timeout time.Duration) ([]byte, []byte, error)` is `pairPolicyRunFn`: one deadline of `timeout` covers `acquirePythonSlot` and `spawnPythonProcessWithEnvLimited(pairPolicyScript, ...)` with `PYTHONDONTWRITEBYTECODE=1`, a 262144-byte stdout ceiling (`pairPolicyMaxStdoutBytes`) and a 65536-byte stderr ceiling (`pairPolicyMaxStderrBytes`).
The shared permit is released after process cleanup, and an overflow, a timeout, a cancelled or saturated admission or a nonzero exit is a runner error that holds the slot.

**Evaluation order.** Version and policy, then parameters (`invalid` at stage `params`), then `pair_inputs.verify_envelope(request)` (`invalid` at stage `evidence`), then the member fields.
Economic inputs come from the verified envelope (capability meta, account, conversions and base-quantity levels with native prices), the request members (`cost_policy` `taker_fee_pct` and `slippage_pct` as fractions, contract `initial_margin_rate` and `maintenance_margin_rate`, the frozen `leverage`, `booked_base_qty` and `booked_avg_px`) and the request `limits`.
The fee rate is the member `taker_fee_pct`, which is the #1887 binding rate, so the request stays version 2.

**Entry economics** (lifecycle `flat`; a booked quantity on either member holds).
All arithmetic uses exact rationals; every amount is USD through the member's saved cutoff conversion for its price currency (account amounts through its settlement currency), and no stablecoin is assumed equal to USD.
Per leg `r = max(adverse_band_bps / 10000, slippage_pct)`; asks become `P × (1 + r_buy)` and bids `P × (1 − r_sell)` in native prices before conversion and the depth walk.
For base quantity `q`: `B` is the walked buy cost, `S` the walked sell proceeds, entry fees `E = fee_buy × B + fee_sell × S`, the exit-fee reserve is `E` again, the exit adverse reserve is `r_buy × B + r_sell × S`, the funding reserve is `B × funding_allowance_bps / 10000` (once, with no credit for funding receipts), and `capture = S − B − 2E − exit adverse reserve − funding reserve`.
Entry needs `capture > 0` and `10000 × capture > entry_threshold_bps × B`; equality holds.
Proposed native bounds are the worst consumed ask × `(1 + r_buy)` and the worst consumed bid × `(1 − r_sell)`.
The exact proposed bound is projected to the safe-side float (a buy cap to the largest float whose value and shortest decimal are at or below it, a sell floor to the smallest at or above it); that float is the target `price_bound`, `pair_evidence.round_bound` gives the sizing-rounded bound, and a rounded bound beyond the exact bound is refused.
Minimum notional is checked in native price-currency units at `min(proposed, rounded)` (the rounded buy cap, the proposed sell floor), because the target carries the proposed bound and `pairLegalLot` checks the minimum there.
Reachability: `pair_inputs.bound_crosses` must hold for the rounded bound, and the saved depth at or inside it must cover `q` on both sides.
Caps and margin use `max(proposed, rounded)` × the price conversion (the proposed buy cap, the rounded sell floor): gross and per-venue notional within `gross_budget_usd` and the venue cap (equality passes; equal opposite targets leave no unmatched notional).
Per leg with notional `N`, `N × max(initial_margin_rate, 1 / leverage) + taker_fee_pct × N` must not exceed the account `free_collateral`, and `equity − taker_fee_pct × N` must not fall below `N × maintenance_margin_rate` (account amounts converted, margin used kept as evidence only); these mirror `pairPlan.reading`, and Go stays the final admission and execution authority.
Candidates are common multiples of both base lots (`lot_step × contract_size`, exact least common multiple) that meet both native minimums (`min_qty × contract_size`).

**Size search.**
The quantity range is cut at the union of both walked sides' cumulative depth; on each interval `(lo, hi]` (a breakpoint belongs to the level that ends there) both walks are affine and the worst levels, bounds and reachable depth are fixed, so every condition is an exact linear bound on `q`.
The search solves the intervals from the deepest down, takes the largest grid multiple inside the first nonempty intersection, recomputes that candidate directly (`entry_candidate`) and fails the run if the two disagree; a failing deeper interval never hides a smaller passing one, and the work is bounded by the saved levels, never by the number of lots.
Both orientations are searched independently; the larger quantity wins, and an equal quantity falls back to the buy member's, then the sell member's, full evidence key `(partition, provider, account_key, instrument, member_id)` in byte order (with uncrossed books both orientations cannot pass at once).
With no passing quantity the result is a hold naming each orientation's deepest candidate failures.
Targets are `+q` for the buy member and `−q` for the sell member, each with its safe-side proposed native bound, and `orientation` is `long_hyperliquid_short_okx` or `long_okx_short_hyperliquid`.

**Matched exit.**
The booked signs identify the long and the short; both must be finite, nonzero and opposite, otherwise the policy holds.
The quantities may differ (a hedge short of one lot still matches the pair), so each leg walks its own booked size: the long sells into its bids and the short covers from its asks, each with its closing adverse rate.
Remaining spread is per base unit, `10000 × (cover_cost / q_short − sale_proceeds / q_long) / (sale_proceeds / q_long)`; at or below `exit_threshold_bps` (equality exits) the result is `exit` with zero targets and the safe-side closing bounds, and above it the policy holds.
Closing fees, net liquidation and an estimated round trip from `booked_avg_px` are recorded separately; a convergence exit can lose money.
Missing closing depth, an unroundable bound or a rounded bound that cannot reach the depth holds the policy; coordinator protection, the hold-age exit, queries and recovery keep their authority.

**Results and the Go boundary.**
Every result carries `v` 2, the request identity (`pair_id`, `slot_ms`, `contract_fingerprint`, `input_fingerprint`), `status` and a `decision` object; reasons are cut to 1024 bytes.
The decision records the policy, stage and reason, the effective parameters, the evidence hashes and receipts, each member's rules, conversions, rates and account readings, up to 16 interval summaries and the deepest candidate per orientation, and either the selected entry candidate (grid, native quantities, walked amounts, fees, reserves, capture, proposed, rounded and emitted bounds, notionals and margin readings) or the `exit` block with both booked quantities and their difference.
Exact values are decimal strings, and a non-terminating value is written `n/d`.
`decidePairSlot` keeps the failed-process check before parsing; `pairDecodePolicyResult` then requires one strict object (`pairStrictParse`: no duplicate keys at any depth, no trailing value) with only the known fields, integer `v` and `slot_ms`, string identity and status, a `decision` object of at most 65536 bytes (`pairPolicyMaxDecisionBytes`) and strings of at most 2048 bytes (`pairPolicyMaxReasonBytes`).
Per status, `enter` needs an orientation and targets, `exit` needs targets and no orientation, `hold` and `invalid` carry no targets or orientation, a hold carries no error and an invalid result no hold reason; each target is exactly `{target_base_qty, price_bound}` with finite numbers.
`validatePairPolicyResult` and the lifecycle, legal-lot and full-fill cap checks follow; `pairFullFillCapCheck(rt, env, targets)` converts each bound with the verified envelope's price-currency conversion, and missing conversion evidence holds.
A semantic `invalid` result is frozen as a hold that keeps its decision evidence; a schema failure is frozen as a hold without it.
`freezePairTargets` replaces a record above 98304 bytes (`pairTargetsMaxBytes`) by a hold whose decision is `{"oversize_bytes":N}`.

**Registration (#1878).** `shared_strategies/open/pair_registry.py` is the only pair authority: `PAIR_CONTRACT` (`{"input": "pair_policy_request_v2", "output": "pair_policy_result_v2"}`), `pair_policies()` (sorted names), `pair_descriptor(name)` (a copy with `contract`), `get_pair_evaluator(name)` and `UnknownPairPolicy` (a `LookupError` subclass raised only for an unregistered name).
Enumeration never loads an evaluator; `get_pair_evaluator` loads it by repository-relative path under its descriptor module name with bytecode writes suppressed, refuses a cached module from another file, drops a failed import from `sys.modules` and propagates every import or execution error.
`registry.py` loads the same module by path, re-exports `PAIR_CONTRACT`, `UnknownPairPolicy` and `get_pair_evaluator`, adds `pair_evaluators()` and projects each descriptor into `STRATEGIES` (`pair`, `pair_fn` as a lazy evaluator, `pair_param_keys`, an empty `default_params` and a `fn` that refuses one-symbol evaluation); `cross_exchange_perp_arbitrage` is `no_edge` with `edge_source="unvalidated"`, evidence `backtest/candidates/cross_exchange_perp_arbitrage_1655/REPORT.md` and `production_refused=True`.
Ordinary admission: `strategy_composition` resolves a name that the injected single-symbol lookup does not know through a cached canonical registry load (`_go_trader_composer_open_registry`) and refuses a production-refused name at the positional, open, close-fallback and positional-under-override references; unknown names keep their errors, so spot-registry check scripts now report the production refusal for futures-only refused names.
Offline: `refuse_pair_evaluator` (`backtest/registry_loader.py`, also reached through `refuse_quote_capability` and `refuse_basket_evaluator`) is the first statement of `run_single_backtest` and `walk_forward_optimize`, runs in `run_walk_forward`, `compute_parity_frame` and `extract_fills_report` before their frames are used, and in `parity_diff.main` before `load_cached_data`.
Research admission is unchanged: `pairPolicyStrategies`, `pairPolicyParamKeys` and `pairPolicyParamValidators` stay the Go authority and never consult `productionRefusedStrategies`.

**Probe.** `probeCheckScripts` registers `run_pair_policy.py --probe-only` (`pairPolicyScript`, `pairPolicyProbeArgv`) once whenever any strategy names a check script, beside the shared-script probes; startup and `go-trader probe` both reach it, and a deploy tree without the script fails the probe (exit 78).

**Tests.** `TestBasketPairPolicyReal*` (`pyintegration`, local only; CI excludes Python-backed basket tests) runs real admission, the evaluator process, the coordinator and SQLite together, compares the bounded search with an exhaustive common-lot enumeration and covers the boundary, exit, process-failure, determinism and import-isolation cases, frozen-request replay after later quote and account inputs change (`TestBasketPairPolicyRealFrozenRequestIgnoresLaterInputs`) and canonical load failures in isolated runner copies (`TestBasketPairPolicyRealCanonicalLoadFailuresHold`); `TestBasketPairPolicyParams*` covers the raw parameter refusals in the default Go suite.

### Pair cadence, clocks and host order (#1873)

Issue #1873 gives research pair hosts one coordinator contract for wakes, sealed reads, clocks, the policy budget and the timeout marker, and adds the integration host `pairTestHost` (`scheduler/basket_pair_helpers_test.go`).
It activates no pair: the replay and paper hosts are #1881 and #1882, which call the steps below in this order and consume the incomplete marker; #1876 adds `bc.preparePairRisk(in)` and its line in the order; #1872's children (#1888) own pair funding; #1876 and #1879 adopt `readPairReference` for their consumers.

**Pair host order.** One host cycle at logical time `now`:
1. Deliver the evidence visible at `now` (the seals `PairSnapshotFor` returns, recorded or archive reads, frozen evidence).
2. Apply funding due at `now` through its owning funding contract with the same-time inventory and visibility rules of #1888. This is a documented downstream insertion: no pair funding exists until that contract lands, and #1873 claims no funding parity.
3. Call `bc.preparePairRisk(in)` (#1876) with `Now`, `PairSnapshotFor` and `PairGeneration`; it runs on every cycle, wake-only and save-blocked cycles included, and publishes the valuation view that step 4 and step 5 read.
4. When every active partition is save-blocked (`allPartitionsSaveBlocked`), skip risk and management and call `bc.step` with `SaveBlocked: true`. Otherwise measure each `activePartitions` partition with `measureScopeCycleRiskMarked` and the lock-held `bc.pairMarksLocked()` (under one `RLock`, never a second read lock), apply it with `applyScopeCycleRisk` and the paper kill switch `applyPaperKillSwitchCycle` (under `Lock`), and on a management cycle call `bc.beginCycle()` and run native member management (the Hyperliquid perps step and the shared OKX step) for the pair members.
5. Call `bc.step(in)` with `ScopeRisk`, `PairSnapshotFor` and `PairGeneration`.
6. Save every partition (`store.SaveAll` under `Lock`).
7. Read fresh time and schedule the next cycle at the minimum of the host schedule and a nonnegative `bc.wakeDelay(now)`. A sub-second host never uses `adjustDelay`, which clamps every wake to at least one second.

**Management freshness.** `bc.beginCycle()` runs only on a host cycle that runs native member management; a wake-only step (no member due) calls neither.
A pair increase needs, for each member, a validated management echo from the current or the previous management cycle, observed (`basketMemberObservation.AtMs`, the coordinator clock at `observePair`) no more than `pairManagementMaxAge(cfg)` (2 × `interval_seconds`) before the increase time, with ATR above zero; otherwise increases hold and exits, queries and protection continue.
Injected observations prove nothing about host order; the Python-backed host check runs the real member steps.

**Wakes.** `(bc *basketCoordinator) pairWakeDelay(now time.Time) time.Duration` takes the read lock; `wakeDelay` folds `pairWakeDelayLocked(now)` in under its existing lock (never a recursive read lock) and ordinary basket timing is unchanged.
Boundaries per admitted pair runtime, all from saved records and checked arithmetic (`pairAddMs`): the current cadence slot start and the next one; a frozen undecided slot's useful decision-window end; each pending non-native action's acceptance deadline and its strict absence boundary (deadline + `entryRecoveryGrace` + 1 ms); a native-booked action's inclusive boundary (deadline + grace); an eligible protective retry (`recovery` or `operator_held`, nonzero exposure, no pending protective reduction); the maximum hold age (`opening`, `matched`); the recovery age (`recovery`).
A future boundary contributes its delay; a passed boundary wakes now only when it passed after the last step's start (`basketRuntime.pairLastStepMs`), so a step always settles a passed boundary and repeated calls never form a zero-delay loop.
Held, reserved, terminal, blocked and durability-pending work therefore waits for the next cadence boundary, which is also the retry interval of a failed save (at most `decision_cadence_ms`); queries of unresolved actions run on every cadence step and no query deadline is persisted.
It returns -1 when no pair deadline exists. A restart starts with `pairLastStepMs` zero, steps once, and then rebuilds the same schedule from the owning partition's saved slots, actions and progress.

**Sealed reads.** `basketCycleInputs` adds `PairSnapshotFor func(rt *basketRuntime, at time.Time) *marketSnapshot` and `PairGeneration func() uint64`; ordinary `Snapshot` and `SnapshotFor` keep their basket behavior and are never a pair fallback.
`PairSnapshotFor` contract: the coordinator calls it only for an operation that reads a `sealed_feed` member, once per collection or reference read, at that operation's read-start time `at`, through `bc.unlocked` (without `mu`; it may block, and it may take `mu`); it may read only the immutable `rt.Contract` and `rt.Partition`.
It returns the immutable seal valid for `at`, covering `pairFeedBookNeeds(rt)` merged through `feedRequirements.addBook`, with a printable `EvaluationID` of at most 256 bytes, `ConfigGeneration` at least 1 and equal to `PairGeneration()`, and a seal time (`Deadline`, else `SealedAt`) not after `at`.
Because it is never called twice for one operation, a host may return its newest seal; a newer seal that arrives during the read never invalidates the captured one, and each later operation obtains its own.
`PairGeneration` returns the host's current feed configuration generation without blocking; the coordinator reads it before the read and again after it, and a move holds.
A missing callback, a nil seal, a missing generation callback, a missing identity, a seal after `at`, a generation mismatch, an unavailable section and stale observations hold the sealed read with no public, cycle-snapshot or private substitute. Public, archive and recorded members that need no seal need no callback.

**Reference reads.** `(bc *basketCoordinator) readPairReference(ctx context.Context, rt *basketRuntime, in basketCycleInputs, member pairContractMember, side string) (pairObservation, error)` takes the coordinator lock; a caller already holding it uses `readPairReferenceLocked` with the same arguments.
It checks runtime ownership, the admitted member (provider, account, instrument, source), the side (`buy` or `sell`) and the quote source, fills `pairReferenceRequest`, reads the seal by the rule above and calls the source's `Reference` outside `mu`, bounded by the caller context and `collection_timeout_ms`.
On return it refuses changed pair records, a changed contract, a read error, an ended context, a used-up wall budget, a completion at or after the logical deadline, a moved generation and a snapshot identity that does not match the captured seal, then validates the observation with `pairCheckReference` at the fresh completion time; any failure returns an error and no substitute observation.

**Policy budget.** `(bc *basketCoordinator) pairPolicyBudget(rt *basketRuntime, rb *basketRebalance, now time.Time) (time.Duration, bool)` verifies the saved envelope; `decidePairSlot` uses `pairPolicyBudgetFor(rt, rb, env, now)` and `pairPolicyWindowFor(rt, rb, env)`.
Real mode: the minimum of `pairPolicyMaxTimeout`, the next slot minus now, the envelope cutoff plus `quote_age_ms` minus now, and the earliest verified member `first_receipt_ms` plus `quote_age_ms` minus now (a member receipt before the cutoff bounds first); a nonpositive or overflowing bound holds without a policy call.
Simulated mode (`tradingClockBound()`): a fixed wall budget of `pairPolicyMaxTimeout`, with logical eligibility checked before launch; wall time never advances the simulated clock.
In both modes `decidePairSlot` reads fresh time after the runner returns and holds an `enter` or `exit` result outside its useful window: the next slot is strict, the earliest receipt age is inclusive at `quote_age_ms` and the cutoff age is inclusive; a zero launch budget still prevents a launch.

**Action clocks.** Each `executePair` and protective iteration reads fresh time. An increase rechecks, immediately before `ReservePairAction`, the selected quote event's saved envelope, member receipt age and binding (`pairBindIncrease`) and both members' view validity at that fresh time (mark, conversions, capability and account); a failed first dispatch ends the decision held. Reductions gain no new evidence gate: an exit checks mark validity, and plans its tranche, at the time `executePair` started, never a time refreshed between its legs.
The view is prepared once per host cycle: an increase whose view evidence aged during collection or the policy run holds (a first dispatch ends held), so a host's quote age must cover collection and policy time.
After `MarkPairActionSubmitting` the coordinator reads fresh time again and checks the acceptance deadline and, for an increase, the same evidence; inside the unlocked transport boundary it checks an immutable proof (deadline, selected receipt, and for both members the earliest end of the mark, conversion, capability and account validity) against the clock right before `Submit`.
An expiry after reservation sends nothing and ends through `finishPairNoFill` (intent `no_fill`, quote use released, the saved request and client identity unchanged). After a query or submit returns, settlement and the next leg use fresh time; a confirmed fill is always settled through its owner even when the entry evidence aged during transport.

**Timeout marker.** Any end of the policy context (a deadline from any budget bound, or a parent cancellation) is an infrastructure hold: the slot is held with a coordinator decision and pair progress gains one `policy_infrastructure_timeout` obligation (`pairObligationPolicyTimeout`, slot and cause in its detail, first record kept through `addObligationOnce`), saved together through `persistPair` (the `writePairTx` progress transaction).
A failed save sets `needsDurable`: elective work holds, the retry `SavePartition` writes both, and the slot is never decided again; when the pair state changed during a timed-out run the decision is discarded and the marker is still saved.
A runner error without a context end, invalid output, a missing launch budget and a late result are ordinary coordinator holds; a policy `hold` is economic. Neither gets the marker.
The marker survives saves, lifecycle resets, later successful slots, pruning and restart, and it is not recovery evidence (`pairRecoveryObligation` excludes it from the startup, `storage-inspect` pending and wrong-file refusals).
`pairIncompleteEvidence(rt *basketRuntime) (pairObligation, bool)` reports it, a pending unsaved marker included; #1881 and #1882 mark their run results incomplete from it. No table or second state writer is added.

### Pair valuation view (#1876)

Issue #1876 gives research pairs one coordinator-owned valuation view; risk, wallet, sizing and report owners read each member at its own venue price and checked evidence, never at a shared symbol price; a member with no current checked mark is valued conservatively only for a bounded time (Incomplete valuation below).
It activates no pair in `main.go`, adds no table and enables no pair reload; ordinary, read-only, probe and reload loads still refuse pairs.

**Producer and lifetime.** `preparePairRisk` runs as host order step 3 on every cycle, before risk.
Under `mu` it captures the contract, records snapshot (`pairSnapshot`: slots, actions with confirmed, applied quantity, notional, fee and fee version, member books with cash and average cost, lifecycle and progress sequence) and a preparation sequence; the reference read (`readPairReferenceLocked`, the once-per-read `PairSnapshotFor` and generation checks) and the frozen evidence reads (`Capability`, `Account`, `Conversions` at the fresh read time) run outside `mu`.
It then rechecks the sequence, runtime, records, contract and context and publishes both members in one write: a failed, cancelled, late or changed read publishes explicit invalidity with its reason, and an older successful view never stays as authority.
A mark read failure keeps the independently read conversion evidence, so settlement with a valid conversion continues.
The view lives in process memory on the pair runtime; a restart or a new coordinator starts with none, and increases hold until a preparation succeeds.
Preparation sends no order, reserves nothing, runs no policy and never writes progress, so it also runs on save-blocked cycles.
A host that calls it without `PairSnapshotFor` or `PairGeneration` while a pair has a sealed member gets one CRITICAL per pair (`notePairPrepHostInputs`, kept in process memory) and the sealed member holds; `main.go` admits no pair today, so its bare call raises nothing.

**Reader validity.** Publication flags never extend evidence: every reader checks at its own time (`usdMarkAt`, `conversionAt`, `capabilityReasonAt`, `accountAt`).
A saved envelope stays the authority for its own decision and request binding (`verifyPairEnvelope`, `(pairQuoteEnvelope).member`); current view evidence never replaces saved bytes, hashes, conversions or bindings.
Prices convert with `PriceCurrency` (planning upper valuation, partition gates, booked fill price) and fees and collateral with the settlement or collateral currency; base quantity is already normalized, so contract size applies only at the native boundary.
The full-target check at decision time uses the verified envelope's conversions and account record with current books.

**Collateral rule.** Accounts group by `(partition, provider, account_key)`; members sharing a resource must carry the same account record, else increases hold.
Book free collateral = cash − posted initial margin (`|booked| × avg cost × max(initial_margin_rate, 1/leverage)`) − margin and fee of confirmed unapplied increase fills; posted margin already sits in perpetual cash and is never added to equity.
Venue free collateral = account free collateral (USD) − the same book commitments − booked costs (`max(0, initial_capital − cash)`), because account evidence has no inclusion watermark and a later receipt never proves a fill, fee or margin is reflected.
Effective free collateral is the smaller of the two; equity is the smaller of book cash and account equity less booked costs, plus the adverse unrealized result of booked, unapplied and endpoint fills, less fee reserves.
Every endpoint of every unresolved request reserves its added initial margin and fee once and checks maintenance margin against that equity; holds come only from conflicting records, ownership mismatch, invalid evidence or insufficient adjusted collateral.

**Incomplete valuation (risk rule).** A pair member with exposure but no current mark is `stale`, `substituted` or `unavailable`, and every aggregate that includes it is incomplete.
Risk values it conservatively (the worse of the last checked mark and its average cost, average cost with no mark) and passes the partition reading as untrusted: no peak raise, no peak rebaseline, the drawdown substitution label, and an over-limit reading defers through `untrustedEquityLatchDeferral` and then latches; it never vetoes the latch.
Paper `EquityTrusted` stays true; the pair state is the separate `PairValuation` signal.
An incomplete partition also holds new opens through the notional cap (when configured) and both exposure-cap directions, and the member circuit-breaker drawdown uses the same conservative value without a peak raise.
Increases and leaderboard scoring hold; queries, settlement, protection, recovery and native closes continue.
**Time limit.** Each published view carries `MarkLostAtMs`: 0 while the mark is valid; the first invalid publication takes the previous valid view's evidence expiry (`EvidenceMs + MaxAgeMs`, capped at the publication time), or the publication time after a restart; later invalid publications keep it; a valid publication clears it, and a valid view that ages at the reader counts from its expiry.
The partition also saves `PortfolioRiskState.PairValuationIncompleteSince` (`portfolio_risk.pair_valuation_incomplete_since`, written by `applyScopeCycleRisk` through `recordPairValuationIncompleteSince`): the earliest start among its incomplete members, kept across restarts and cleared by the first complete reading; `pairMarksLocked` passes it to every member, but a member's own start (`sinceFor`) takes it only when the member's in-memory start has no earlier knowledge (no view, or `MarkLostAtRestart`: the first invalid publication after a restart), so restarts never move a start forward and a member that goes stale later keeps its own start.
When the partition valuation stays incomplete without a break for `untrustedEquityLatchDeferral` from the earliest of its members' own starts and the saved start (`pairMarks.partitionOverdue`, `scopeCycleRisk.PairValuationOverdue`, `PairValuationOverdueMs`), the reading counts as over the limit and latches when `max_drawdown_pct` is set (event source `pair_valuation`); when a member that holds a position stays incomplete for that long from its own start (`pairMarks.overdue`), `checkPairMemberRisk` trips the enabled member circuit breaker with `RiskReasonPairValuationStale`; the breaker books no close without a checked price.

**Wallets and reports.** `walletKeyFor` and `configuredWalletKeyFor` never group a pair member; pair totals stay book-derived, and account evidence only bounds collateral and appears once per resource in `pair_accounts`.
Status totals and rows, strategy overview and equity views, warning contributors and position text, Discord status, positions, P&L, strategy summaries and leaderboard, and the exposure-status note read the view through `state.pairMarksLocked()` captured under the state lock and label incomplete values; an incomplete leaderboard entry is unranked and outside totals.
The quote admission gate values pair members through the same accessor; the basket gate never sees pair members because pair research configurations refuse baskets.

**Contracts for later children (#1879, #1881, #1882).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairMemberValuation` | `Key pairMemberKey`, `Symbol string`, `ContractFingerprint string`, `EvidenceHash string`, `Generation uint64`, `Mark`, `UpperPx`, `LowerPx float64` (native mid, best ask, best bid), `EvidenceMs`, `MaxAgeMs int64` (reference receipt, `quote_age_ms`), `ContractSize float64`, `Conversions map[string]float64`, `FeeRate float64`, `PriceCurrency`, `SettlementCurrency string`, `MarkUSD`, `LowerUSD`, `UpperUSD float64`, `Observation pairObservation`, `Capability pairCapabilityEvidence`, `ConversionEvidence []pairConversionEvidence`, `Account pairAccountEvidence`, `MarkValid`, `ConversionValid`, `CapabilityValid`, `CollateralValid`, `Substituted bool`, `MissingReason string` | one record per member per publication; `FeeRate` is the member binding `CostPolicy.TakerFeePct`; `Conversions` holds `USD`=1 plus one rate per checked record; `Substituted` means `MarkUSD`/`LowerUSD`/`UpperUSD` come from the member's last checked mark and grant no authority |
| `pairViewSet` | `map[string]pairMemberValuation` | keyed by runtime member strategy id, which equals the contract member id and the in-memory book id; each entry carries the full partition, provider, account, instrument and member key; stored ids translate only inside the storage owner |
| `usdMarkAt` | `func (v pairMemberValuation) usdMarkAt(nowMs int64) (px float64, valid bool, reason string)` | valid only when the view mark is valid, the receipt is not after `nowMs` and within `quote_age_ms` of it, and the `PriceCurrency` conversion record is eligible at `nowMs`; `px` = native mid times that rate |
| `usdBoundsAt`, `conversionAt`, `capabilityReasonAt`, `accountAt`, `increaseReasonAt`, `evidenceLimitMs`, `depthBound` | methods on `pairMemberValuation` | each rechecks its own record's eligibility at the reader time; `accountAt` returns `pairAccountUSD{Resource, EvidenceHash, Equity, Free, MarginUsed}` converted once with the collateral currency; `increaseReasonAt` is empty only when mark, every conversion, capability and account are valid; `evidenceLimitMs` is the earliest end of those intervals |
| `preparePairRisk` | `func (bc *basketCoordinator) preparePairRisk(in basketCycleInputs)` | takes the coordinator lock; per pair one `readPairReferenceLocked` read per member (side `sell` for a long book, else `buy`) and the frozen evidence reads outside `mu`, all under one context of `collection_timeout_ms` from `bc.parent`; publishes both members atomically or discards its result when a newer preparation started |
| `pairViews`, `pairViewsLocked` | `func (bc *basketCoordinator) pairViews(now time.Time) pairViewSet`, `func (bc *basketCoordinator) pairViewsLocked(now time.Time) pairViewSet` | detached deep copies (maps, slices, observation payload and levels, capability metadata) with the validity flags re-evaluated at `now`; `pairViews` takes the read lock, the locked form is for callers that already hold `mu` |
| `pairMarksLocked` | `func (bc *basketCoordinator) pairMarksLocked() *pairMarks`; `func (s *AppState) pairMarksLocked() *pairMarks` | caller holds `mu`; detached copies stamped with the coordinator clock; `AppState.pairViews` is set by `newBasketCoordinator` only when it admits a pair, so ordinary state returns nil |
| Marked readers | `portfolioValueMarked`, `portfolioNotionalMarked`, `perpsMarginDrawdownInputsMarked`, `aggregatePerpsMarginInputsMarked`, `computeAssetDeltasMarked`, `computeCorrelationMarked`, `evaluateExposureCapMarked`, `evaluateNotionalCapHoldMarked`, `computeSubsetPortfolioValueMarked`, `computeTotalPortfolioValueMarked`, `displayStrategyValueMarked`, `latestDisplayTotalMarked`, `latestDisplayTotalForPartitionMarked`, `computeSubsetDisplayValueMarked`, `measureScopeCycleRiskMarked`, `checkPairMemberRisk` | each takes `pm *pairMarks` and returns `pairValueStatus`; the legacy names that remain are nil-view wrappers and keep their results for ordinary strategies (Nil-view rule) |
| Nil-view rule | `CheckRisk`, `checkStrategyRiskWithView(sc, s, pv, prices, pm, logger, assist)` | notional and margin readers have no wrapper, so no caller can drop their status; `CheckRisk` without a view holds a pair member with a position (an active circuit breaker keeps its own reason) and changes no peak or drawdown; `main.go` and `basket_replay.go` call `checkStrategyRiskWithView` with `state.pairMarksLocked()`, which runs `checkPairMemberRisk` for members; manual-open notional and exposure gates and the basket partition gate read the same view and hold only while it is incomplete |
| `MarkLostAtMs`, `MarkLostAtRestart`, `incompleteSinceMs`, `sinceFor` | `pairMemberValuation.MarkLostAtMs int64`, `pairMemberValuation.MarkLostAtRestart bool`; `func (pm *pairMarks) incompleteSinceMs(id string) (since int64, known, priorUnknown bool)`; `func (pm *pairMarks) sinceFor(id string) int64` | `MarkLostAtMs` is process memory; `MarkLostAtRestart` is true when the first invalid publication had no earlier view (a restart or a new coordinator), and later invalid publications keep it; `priorUnknown` is true when the member's start has no earlier knowledge (no marks, no view, `MarkLostAtRestart`, or no recorded start); `sinceFor` takes the saved partition `PairValuationIncompleteSince` only when `priorUnknown` is true and the saved start is earlier or no start is known, else the member's own start, else the reader time |
| `overdue`, `partitionOverdue` | `func (pm *pairMarks) overdue(st pairValueStatus) map[string]int64`; `func (pm *pairMarks) partitionOverdue(st pairValueStatus) (int64, map[string]int64)` | `overdue` lists members whose own start (`sinceFor`) is at least `untrustedEquityLatchDeferral` old; `partitionOverdue` returns the partition start (the earliest of the members' own starts and the saved start) and every incomplete member once that start is that old |
| `pairValueStatus` | `Members map[string]string`, `Reasons map[string]string`; `complete() bool`, `label() string` | member state `stale`, `substituted` or `unavailable`; any entry makes an aggregate incomplete |
| `scopeCycleRisk.PairValuation`, `ExposureCapStatus.ValuationIncomplete`, `LeaderboardEntry.Incomplete` | `pairValueStatus`, `string`, `string` | carry the incomplete state to the risk owner, the exposure gates and the reports |
| Status JSON | `pair_valuation`, `pair_valuation_by_scope`, `pair_accounts` (`pairAccountDisplay`), member row `pair_valuation`, UI overview `pair_valuation`, equity view `pair_valuation` | present only when a pair is admitted or incomplete; ordinary output is unchanged |

Boundaries: native kill-switch and circuit-breaker close booking prices stay with #1914 and required reduction references with #1879; the version-2 replay host and its output proof stay with #1881 and #1882, which call `preparePairRisk` at host step 3, measure risk with `measureScopeCycleRiskMarked` and the lock-held marks, and run `checkPairMemberRisk` for members.

### Pair bounds, transport and the position barrier (#1877)

Issue #1877 freezes the rounded policy bounds, enforces crossing and full depth at dispatch, adds the strict pair order, query and position routes on both venues, the fixture-only live-shaped venues and the generation-checked venue position barrier.
Production pair activation stays refused, research admission stays synthetic and virtual, and no deployed configuration, default, credential or promotion changes.
Reduction reference bounds and the reduction policy are #1879 (Coordinator reduction references below).

**Saved bounds.** `decidePairSlot` validates a policy result in three stages: identity, schema, finite values and lifecycle (`validatePairPolicyResult`, which checks lots without minimum notional); rounding (`pairRoundTargets`); and economics (`pairRoundedEconomics`).
Each side comes from the target change against the member exposure; a zero-target exit closes the nonzero exposure.
`pairRoundBound` (now `pairCapabilityMatches` plus `pairRoundBoundRule`) rounds with the verified envelope capability: a buy cap down, a sell floor up, never wider; the policy's rounded diagnostic fields are not authority.
`pairTargetsVersion` is 2: `pairTargets.BoundEvidence map[string]pairBoundEvidence` (`bound_evidence`) with `pairBoundEvidence{ProposedBound "proposed_bound", RoundedBound "rounded_bound", Side "side", CapabilityHash "capability_hash"}`, and `Members` holds the rounded bounds.
Minimum notional is checked at `min(proposed, rounded)` for the entry target and for an exit's nonzero close tranche; caps and margin use `max(proposed, rounded)` with the saved conversions (`pairFullFillCapCheck` on a conservative copy).
Tranche planning and every unresolved increase keep the same conservative bound (`pairCandidate.riskBound`, `pairRiskBound`), and `legalTranche` checks the venue minimum for reductions too.
A failure at any stage freezes a version-2 hold without bound evidence.
Policy, action-request and contract versions are unchanged.

**Legacy records.** `parsePairTargets` reads version 1 and 2; a version-1 record keeps its bytes and never receives bound evidence.
An active version-1 `enter` or `exit` decision authorizes no order: with no unresolved action and no exposure its slot ends held, otherwise recovery opens (`holdPairLegacyOrigin`); saved actions keep their queries and settlement.
A version-2 actionable record must pass `pairCheckSavedTargets` (every member, side, hash, positive bounds, safe-side rounding, saved bound equal to the rounded bound, on the venue grid) before dispatch.

**Dispatch checks.** An increase rechecks, against the selected event's verified envelope, that the saved bound is a legal price under that event's capability, that it crosses the best level (`pairBoundCrosses`, equality crosses), and that the saved depth at or inside the bound covers the full exact tranche in base units (`pairDispatchBookCheck`, `pairDepthWithin`).
A failing first leg ends its slot held with no reservation; a failing hedge after a first-leg fill opens recovery at once; a failing later first-leg tranche only holds.
Order quantities are exact: `pairExactOrderQty` computes native = lots × lot step and base = native × contract size as rationals and refuses unless each float's shortest decimal is exactly the grid value; the action's requested quantity and both request quantities use these values.
A live-shaped venue needs a finite positive bound on the venue grid before reservation (`pairBoundOnGrid`); a hold-age exit rounds its fallback bound safe-side or holds.
Protective and hold-age reductions take the #1879 reference width bound; a reduction without a fresh saved reference holds (Coordinator reduction references below), and no market or uncapped route exists.
A nonzero exit remainder below the venue minimum after rounding keeps a visible `reduction_below_minimum` obligation and one CRITICAL alert; the flat check clears both obligations.
For a live-shaped admission the reservation transaction (`pairCheckLiveRequest` in `pairValidateActionRequest`) also requires the bound on the grid and equal to the action's, exact quantities, and, for increases and decision exits, the bound and side of the saved bound evidence.
The transport proof keeps the deadline, the selected receipt within `quote_age_ms` and the per-member view evidence limit (`evidenceLimitMs`, #1876) and adds the saved request deadline the child checks, the crossing and depth result and the barrier freshness limit.

**Order and query routes.** `check_hyperliquid.py` and `check_okx.py` accept exactly one of `--pair-order-json=<pairActionRequest>` and `--pair-query-json=<pairActionRequest> --since-ms=<ms>`, plus `--mode=live`; `check_hyperliquid.py` and `fetch_okx_positions.py` accept `--pair-position-json=<{v:1, provider, account_key, instrument, member_id, partition}>`.
`shared_tools/pair_orders.py` parses requests strictly (`loads_strict`, exact decimals, known fields only, version 2, provider, purpose `pair_entry`, `pair_exit` or `pair_protective` with the matching `reduce_only`, side and position side, base = native × contract size, instrument, account and client id formats); native-booked purposes never use the route.
`--probe-only` returns after the strict parse and before any clock, adapter or venue call; malformed input, a past deadline, a mismatched credential account, an off-grid size or price (Hyperliquid `check_quote_request`, OKX instrument grid and SDK serialization through `pair_order_check`) and a deadline reached immediately before the order call all answer `never_sent` with no order call.
Hyperliquid sends `limit_open(tif="Ioc", cloid, reduce_only by purpose, require_exact=True)` after `set_order_expiry(deadline)`; OKX sends `pair_limit_ioc` (`create_order` limit, `timeInForce` IOC, `tdMode` cross, `clOrdId`, `reduceOnly`, `expTime` header restored on every exit).
After the order call the child reads the order record by client id and scans fills (`scan_recovery_fills` with attribution, `scan_okx_client_fills` keeping rows) and answers once terminal, with the per-fill records in the same submit answer, so a fully covered first leg commits without waiting for a query.
Go sends with `runPythonSideEffectWithTimeout` after its own deadline check; queries and position reads enter through `acquirePythonSlot(ctx)` and `spawnPythonProcess` within the caller context.

**Outcome mapping** (`pairMapScriptOutcome`). Venue-observed fields (credential account, order instrument, client id, side, original size, limit price, time in force, reduce-only flag, contract size, price currency, native unit) are kept apart from the request echo; any missing or different field makes the outcome `unknown` and queryable.
Terminal nonzero execution is `filled` or terminal `partial`, terminal zero execution `rejected`, a live order `open`, a proven unsent request `never_sent`, an unknown client id `absent` with the fill-scan coverage and contrary evidence; fills visible below the executed size stay non-terminal.
`accepted_at_ms` comes only from the venue record (Hyperliquid order `timestamp`, OKX `cTime`).
Go normalizes once: base quantity and base-unit notional are native values × contract size; it seals `pairConfirmedFill` and `pairFeeFill` records (`fill_id` Hyperliquid `tid`, OKX `billId`), sets `FeeEvidenceVersion` to the fee record count and `FeeEvidenceID` to `pairFeeEvidenceID(action)`, and sets `FeeFinal` only for a terminal answer with complete coverage, execution records summing to the cumulative totals and a fee record for every fill.
Fee signs: Hyperliquid `fee` is the total fee charged ("the total fee, inclusive of builderFee", info endpoint; maker rebates are negative rates, fees page), kept as is; OKX fill `fee` is negative when charged ("Negative number represents the user transaction fee charged by the platform"), negated once; a fill without fee amount or currency has no fee record.

**Live-shaped venues.** `hyperliquidPairVenue` and `okxPairVenue` (`newHyperliquidPairVenue`, `newOKXPairVenue`) are built only for a valid live-shaped fixture admission, one contract member each, with substituted driver scripts (production scripts refused), a clock and the live partition; they read no private valuation.
Under `liveShapedFixture` only, a member `account_key` may use `hyperliquid:<network>:<address>` or `okx:swap:<network>:key-<16 hex>` (`pairAccountKeyError`); research admission keeps the synthetic and virtual checks and refuses `pairResearchDeps.Account`.
The fixture may inject `pairResearchDeps.Account pairAccountReader`; `evidenceSource()` then returns `pairLiveShapedEvidenceSource`, which keeps the frozen capability and conversion selection and the document digest and accepts an account record only with the member key, a valid hash, `pairAccountMatches` and eligibility at the read time.
`OKXPositionJSON`, `OKXPosition` and the legacy positions output gain `inst_id`, `contracts`, `contract_size`, `margin_mode` and `account`; legacy fields and closes are unchanged.

**Position reads.** `pairVenue.Position(ctx, m, nowMs) (pairVenuePosition, error)`; `pairVenuePosition{V 2 "v", Key "key", SignedNativeQty "signed_native_qty", ContractSize "contract_size", PositionMode "position_mode", MarginMode "margin_mode", ObservedMs "observed_ms", FirstReceiptMs "first_receipt_ms", EvidenceID "evidence_id", EvidenceHash "evidence_hash"}`, sealed over its canonical JSON.
Hyperliquid `strict_pair_position` and OKX `fetch_pair_position` refuse a malformed row, a row with no instrument, a duplicate instrument, a foreign instrument (OKX), a non-`oneWay` or non-`net` row, a missing list or a venue error; only a successful complete read proves an absent instrument flat.
Virtual venues make no private read and are never asked.

**Barrier** (`scheduler/pair_position.go`). `pairProgress.PositionMatch` (`position_match`: `generation`, `status` unchecked, matched, mismatch or failed, `reason`, `checked_at_ms`, `episode_ms`, `held_since_ms`) is saved through the progress writer; missing loads as unchecked and the flat reset keeps it.
The generation hashes the partition, contract, member identities and books and every action's status, quantities, terminal flag and native ownership; the lifecycle and barrier fields are not part of it, because neither moves a venue position or the reachable range, so the flat-to-opening flip of a first leg keeps the step's matching read.
`stepPairPositions` runs for live-shaped members after the lifecycle checks unless the partition is latched or the step is save-blocked: at startup, when the generation changed and at each decision cadence, never in a busy loop.
It runs inside the pair step, so its reads always follow `preparePairRisk` (host step 3); live-shaped venues take every mark and exit depth bound from that view.
`pairReadPositions` captures the generation, reads both venues outside `mu` under one `collection_timeout_ms` context, rereads time, discards a changed generation, and requires `observed_ms <= first_receipt_ms <= now` with both within `quote_age_ms`.
A member matches when the venue base quantity lies in `[low, high]` from the book, confirmed unapplied quantity and each possibly sent action's zero-to-remaining fill (endpoints by `basketQtyEqual`); nothing is adopted into the book.
A mismatch opens recovery and sends one CRITICAL alert per episode (`position_match|<episode_ms>`), and the recovery cannot reset to flat while the barrier is a mismatch; a failed, stale, future, malformed or timed-out read holds increases with the saved reason; a later matching read clears only the barrier.
An increase needs an in-memory match for the current generation within `quote_age_ms`: before reservation (reading again if needed), after the submitting mark with the dispatched action excluded as proven unsent, and in the transport proof; a restart starts with no authority.
A one-sided opening held by the barrier past the acceptance deadline plus the absence grace opens recovery.
Queries, settlement, closes and protection run before and regardless of the barrier; a save failure keeps the hold and sets `needsDurable`; the startup line and `storage-inspect` show a mismatch or failure.

**Contracts for later children (#1879, #1880, #1881, #1882, #1863).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairBoundEvidence` | `ProposedBound`, `RoundedBound float64`, `Side`, `CapabilityHash string` (`proposed_bound`, `rounded_bound`, `side`, `capability_hash`) | saved in version-2 `pairTargets.BoundEvidence`; `Members[id].PriceBound == RoundedBound`; dispatch reuses it |
| `pairRoundBoundRule` | `func pairRoundBoundRule(instrument string, meta pairWireMeta, side string, bound float64) (float64, error)` | the grid rounding behind `pairRoundBound`; #1879 rounds reference bounds with it |
| `pairBoundOnGrid` | `func pairBoundOnGrid(m pairContractMember, side string, bound float64) error` | a live-shaped request bound must pass it |
| `pairExactOrderQty` | `func pairExactOrderQty(m pairContractMember, baseQty float64) (pairOrderQty, error)` | exact native and base quantities for every request |
| `pairVenue.Position` | `Position(ctx context.Context, m pairContractMember, nowMs int64) (pairVenuePosition, error)` | live-shaped venues only; replay and paper hosts never read private positions |
| `pairPositionMatch` | `Generation`, `Status`, `Reason`, `CheckedAtMs`, `EpisodeMs`, `HeldSinceMs` | `pairProgress.PositionMatch`, preserved by flat resets |
| `pairPositionAuthorized` | `func (bc *basketCoordinator) pairPositionAuthorized(rt *basketRuntime, exclude string, nowMs int64) string` | empty only for a fresh in-memory match of the current generation; #1879 reductions do not need it |
| `pairAccountReader` | `Account(ctx, m pairContractMember, refMs int64) (pairAccountEvidence, error)` | fixture-only `pairResearchDeps.Account` |
| `pairLiveTransport` | `Script`, `PositionScript string`, `Partition RiskPartition`, `Clock func() time.Time`, `Substituted bool` | the only way to build a live-shaped pair venue |
| Script flags | `--pair-order-json`, `--pair-query-json` with `--since-ms`, `--pair-position-json` | each probed in both probe paths (`pairOrderHLProbeArgvs`, `pairOrderOKXProbeArgvs`, `pairPositionOKXProbeArgv`) |
| Obligations | `protective_bound_unavailable` (member), `reduction_below_minimum` | recovery evidence; cleared when the exposure is flat |

**Tests.** `TestBasketPairBound*` and `TestBasketPairPosition*` run locally (CI skips `^TestBasket`); `TestBasketPairVenueReal*` (`pyintegration`, local only) runs the real scripts and adapters with substituted SDK clients, a driver clock and network and signing guards.
`TestBasketPairVenueRealCoordinatorRunsBothVenuesAndRecoversBySavedIdentity` drives the coordinator through both real scripts: the first leg commits from its submit answer, the hedge follows at the next slot, an exit crashes after venue acceptance, and after a restart the saved client id query settles it with no second order.

### Coordinator reduction references (#1879)

Issue #1879 gives every coordinator reduction (`pair_exit`, `pair_protective`) a saved fresh reference and a bounded reduce-only IOC order, and sizes live-shaped Hyperliquid reductions through the existing close size owner.
Native emergency closes (`pair_kill_switch`, `pair_operator`) keep their separate route: no missing reference, bound, accounting record or pair reservation blocks them, and native evidence, units, fee settlement and paper emergency prices stay with #1914.
No deployed configuration, default, credential, promotion or live activation changes.

**Reference source.** A reduction consumes the reference that `preparePairRisk` published for the member (side `sell` for a long exposure, else `buy`); no consumer reads its own quote.
`pairMemberValuation.ReferenceValid` is true only for a reference read that succeeded with an ordered best bid and ask in a preparation that did not change, cancel or time out; conversion, capability, account and position-barrier evidence are not reduction gates.
`pairReductionReference` binds a detached copy with the view `Generation`, `EvidenceHash`, side and exposure (`pairReductionRef`); `pairReductionStillValid` rechecks the publication, side, exposure, `pairCheckReference` (identity, source, payload, native levels, `quote_age_ms` at fresh coordinator time) and crossing.
The view is process memory, so a restart needs a new publication before any reduction.
Exit valuation and tranche proofs keep the step-start time (#1876); reference age and crossing use fresh coordinator time.

**Bounds.** `pairReductionBound(m, side, ref, nowMs, lim)` refuses an invalid width or a stale reference, takes best bid × (1 − `protective_width_bps`/10000) for a sell floor or best ask × (1 + `protective_width_bps`/10000) for a buy cap, rounds safe-side with `pairRoundBoundRule` (sell up, buy down) and requires the rounded bound to cross the reference level (equality crosses).
Protective and hold-age reductions use it; a policy exit keeps its saved rounded bound evidence (`pairPolicyExitEvidence`) and holds while that bound does not cross the current reference.
The virtual residual rounding exception and exact live-shaped quantities are unchanged.

**Saved request.** A new reduction request saves `Reference` (the detached observation), `ReferenceMaxAgeMs` (`reference_max_age_ms`, the contract `quote_age_ms`), `ReferenceReceiptMs` (`reference_receipt_ms`) and `ReferencePx` (`reference_px`, the native best bid for a sell or best ask for a buy).
`pairCheckReductionAuthority` (inside `pairValidateActionRequest`, at the reservation transaction with the refreshed coordinator time) requires all of them, the committed published reference of that member, the legal bound (saved policy evidence or the recomputed width bound, equal to the action) and crossing; increases and native requests refuse every reference field.
The coordinator rechecks the reference before reservation and after the submitting mark (a failure ends the action never sent), and the transport proof and `pairLiveBase.submit` check `pairReductionAgeRefusal` with fresh time; an expired reference sends nothing even before the acceptance deadline.
Older saved requests stay readable for queries and settlement; missing authority never permits a send.

**Transport projection.** `pairTransportRequestJSON` removes `reference` from the order and query argument, so the argument stays small for any admitted window (Linux refuses one argument above 131072 bytes); the full request stays in the state file.
`shared_tools/pair_orders.py` accepts `reference_receipt_ms`, `reference_max_age_ms` and `reference_px` only on reductions, all together, with a canonical positive level that the bound crosses; the order route (`parse_request(raw, provider, route="order")`) refuses a reduction without them, and the query route accepts it.
`reference_refusal(req, now)` runs after `--probe-only` returns: both check scripts call it before the adapter is built and again immediately before the venue order call, so time spent waiting for a process slot or in adapter setup counts.
The Hyperliquid probe vector (shared by the order and query argvs) carries the three fields.

**Hyperliquid sizing.** A live-shaped Hyperliquid reduction calls `pairSizeHLReduction`: it releases `mu`, takes `lockHyperliquidProtectionSync(symbol)`, captures the exposure (`pairExposure`), peers (`pairHLPeerBooks`: books on the same account and coin, member excluded) and records, refetches through `pairVenue.Position` outside `mu`, rechecks the records, contract and publication, and calls `resolveHLCloseOrder` (`planHLCloseOrder` stays the sole size and mode owner).
Skip, Defer, a netted Cross and a changed record send nothing and cancel nothing; a capped size floors to lots, repeats the exit tranche proof and the venue minimum; the uncapped reduce-only fallback after a failed refetch keeps the saved price bound.
The symbol lock is released after reservation and before transport, so native closes and protection never wait for the order script.
The pair IOC route never cancels protection, so `rearmAfterSizedClose` is not used; paper and virtual reductions call no planner and read no private position.

**Holds.** `holdPairReduction` saves one `reduction_reference_unavailable` member obligation (detail updated, episode kept), sends one CRITICAL alert per episode (`reduction_reference|<member>|<episode>`, the recovery start for protective reductions) and, for protective reductions, the retry backoff. Each hold saves the obligation `retry_at_ms` (the backoff time for a protective reduction, the hold time plus `retry_initial_ms` for an exit). While the member has exposure, `pairWakeBoundaries` wakes the coordinator at the later of that time and the last step plus `retry_initial_ms`, so a held exit retries soon and never more often than `retry_initial_ms`.
A reservation clears it and the legacy `protective_bound_unavailable`, and so does a flat member; recovery-age escalation, `reduction_below_minimum`, queries, settlement and native protection continue.

**Contracts for later children (#1914, #1880, #1881, #1882, #1863).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairReductionBound` | `func pairReductionBound(m pairContractMember, side string, ref pairObservation, nowMs int64, lim pairOperationalLimits) (float64, string)` | the only width bound for protective and hold-age reductions |
| `pairReductionRef` | `Ref pairObservation`, `Side`, `Px string`, `Generation uint64`, `EvidenceHash string`, `Exposure float64` | `pairCandidate.Ref`; built by `pairReductionReference`, rechecked by `pairReductionStillValid` |
| `pairMemberValuation.ReferenceValid` | `bool` | the only reduction authority flag of a published view |
| Request fields | `reference`, `reference_max_age_ms`, `reference_receipt_ms`, `reference_px` | required on new coordinator reductions; refused on increases and native requests |
| `pairCheckReductionAuthority` | `func pairCheckReductionAuthority(rt *basketRuntime, m pairContractMember, a *basketAction, req pairActionRequest, nowMs int64) error` | reservation rule |
| `pairTransportRequestJSON`, `pairReductionAgeRefusal` | `func pairTransportRequestJSON(raw string) (string, error)`; `func pairReductionAgeRefusal(raw string, nowMs int64) string` | every order and query argument; the Go transport age check |
| `pair_orders.reference_refusal` | `reference_refusal(req, now) -> str` | child age check before the adapter and before the order call |
| `pairSizeHLReduction` | `func (bc *basketCoordinator) pairSizeHLReduction(rt *basketRuntime, c pairCandidate, qty float64, v pairVenue, in *basketCycleInputs) (float64, func(), string)` | live-shaped Hyperliquid only; returns the symbol unlock for the caller to release before transport |
| Obligation | `reduction_reference_unavailable` (member) | recovery evidence; cleared by a reservation or a flat member |

Rules: #1880's virtual venue keeps the reference and width bound and never calls `pairSizeHLReduction` or `Position`; #1881 and #1882 publish the view at host step 3 before any reduction; #1914 keeps native requests free of reference fields.

**Tests.** `TestBasketPairReduction*` (untagged, local; CI skips `^TestBasket`) covers fresh and missing references, backoff and alerts, hold-age width bounds, policy-bound crossing holds, the transport-boundary expiry, Hyperliquid capped, uncapped, fallback, skip and changed-record sizing with lock release and no protection call, native closes without a reference, virtual routes, restart and the sealed-window argument size; `TestBasketPairVenueRealReferenceAgeChecksInTheChild` (`pyintegration`) runs both real scripts.

### Virtual pair venue (#1880)

Issue #1880 adds the deterministic virtual immediate-or-cancel venue that research pairs execute against: `pairVirtualVenue` matches price-limited orders against recorded order books, uses each recorded level once and returns the same execution after a restart.
It changes no deployed configuration, default, credential, promotion or live trading behaviour; production pair activation stays refused.

**Construction and attachment.** `newPairVirtualVenue(pairVirtualVenueConfig{Partition, RunTag, MemberID, Provider, AccountKey, Instrument, Market, AdverseBandBps, SubmitLatencyMs, ResponseLatencyMs})` validates a research partition (never live), a synthetic account, the run tag pattern, a market, `0 <= adverse_band_bps < 10000` and latencies in `[0, 86400000]` ms.
`Name`, `Provider`, `AccountKey`, `Virtual` (true), `LiveShaped` (false) and `ExecutionModel` (`virtual_ioc_adverse_max_v1`) are pure, so admission and cost binding call the host factory before any store exists.
After the store opens and `LoadBaskets` builds the runtime, the host calls `bc.attachPairVirtualVenues()` once, before `startupCheck`; it attaches every member venue with the store, the runtime, the member configuration and the coordinator clock (`bc.pairClock`), which is the only venue clock; a nil `bc.pairClock` refuses attachment and attaches nothing.
Attachment checks the run tag against the admission run id, the partition, the frozen contract member, `checkTransport`, the durable cost binding (model version 1001, `pair-cost-` id), the policy `adverse_band_bps` when the policy declares it, and the saved binding record; any difference refuses and writes nothing.
An unattached venue refuses `NewClientOrderID`, `Submit` and `Query`; `pairVenueFor` and `startupCheck` refuse an unattached virtual venue, and nothing executes before the member cost binding is durable.

**Market window.** `pairVirtualMarket.Events(ctx, key pairMemberKey, afterMs, throughMs int64) (pairVirtualMarketWindow, error)` returns `pairVirtualMarketWindow{Events []pairObservation, Coverage pairCoverage}`: every record with `afterMs < venue time <= throughMs` and first receipt `<= throughMs`, in recorded source order (point sources by receipt, windows by source cutoff).
The venue re-reads from the saved acceptance time, validates every event with the strict parser (`pairCheckObservationEvent`, the quote check without the coverage-complete gate), refuses a foreign key, malformed payload, future receipt or broken order, and extracts books: one per public snapshot (identity = payload hash) and one per window record (identity = the record identity, rehashed with `bookIdentity` when the record is not truncated).
A repeated identity with different levels or event time refuses the window; a copy keeps one identity and its earliest receipt.
A book's availability is max(event time, record receipt, receipt of the first window carrying it).
Coverage proves continuity only for window sources, only when `complete`, prefix complete and gap free, and only inside one merged span of the carried windows' own complete coverage; point snapshots, empty answers, timeouts and the request bound prove nothing.
The venue saves a `query` record (through time and digest of available books) when it sees new input; a later read whose books available by that time differ is a changed committed prefix and supplies no fill or absence proof.
#1881 must expose only the frozen prefix at the simulated clock and #1882 must commit each poll and its coverage before returning it; the contract above is the one both hosts implement.

**Identifiers.** `NewClientOrderID()` returns the newest allocation for the run and full member key when it has neither an action binding (provider, account, client id, member and partition checked) nor a venue record (earlier allocations are spent, because a new id is committed only after the newest is spent), else commits `vp-<run_tag>-<sha256(member key)[:8]>-<seq>` before returning it; a collision with any other allocation or action refuses.
Both coordinator sites (`runPairAction`, `beginPairNativeReduction`) allocate and reserve under `bc.mu`; the unique action client index stays the backstop.

**Acceptance.** `Submit` checks identity, the durable cost binding and the deadline (a submission at or after it saves a `deadline` record and answers `never_sent`), then validates the saved request: version 2, identity and contract, purpose and reduce-only direction, side, exact lot quantities (or, for a reduction only, an off-lot quantity equal to the member's full known inventory), a finite positive bound on the venue grid, and a durable submitting action row with the same request and allocation.
When the origin slot holds an actionable version-2 decision of the action's kind (entry for entries, exit for exits) the bound must equal its saved rounded bound evidence and side (`bound_source` `decision_evidence`); a version-1 decision authorizes nothing; an entry needs its decision.
Every reduction also needs its saved #1879 reference authority: `reference` passes `pairCheckReference` at the acceptance time, `reference_receipt_ms` is its first receipt, `reference_max_age_ms` is `quote_age_ms`, `reference_px` is its best level on the order side, and the bound crosses that level; a protective or hold-age reduction's bound must equal `pairReductionBound` of that reference (`bound_source` `reduction_reference`), and a missing or failing authority is a durable `refusal` answered `never_sent`.
A refusal saves a `refusal` record and answers `never_sent`; the first valid submission saves the request, its hash and acceptance time s and answers `open`.
`Query` for an unaccepted client answers `unknown` before the deadline and saves a `deadline` record and answers `never_sent` from the deadline on, so no later submission can accept it; a changed request under a saved identity answers `unknown`.

**Matching.** An eligible book has event time t with `t > s`, `t >= s + submit latency`, `t < deadline`, availability `<= now` and a complete side (`min(declared depth, book depth)` levels, strictly ordered, uncrossed).
The decision uses the eligible book with the smallest availability, then event time, then recorded order, so query timing never changes it; earlier accepted orders of the member decide first.
With r = max(adverse_band_bps / 10000, member `slippage_pct`), a buy walks asks at native price × (1 + r) and a sell walks bids at native price × (1 − r) while the adjusted price is within the saved bound, using only remaining level capacity (capacity key: run tag, member key, source contract, source identity, side, level index).
A reduction is contained to the member inventory at t: earlier virtual executions plus native and unexplained journal rows before t, with an ambiguous row taken at the less permissive endpoint.
Any short fill is floored to the lot; the result is `filled`, terminal `partial` or terminal `rejected` and never waits for another book.
Base quantity is native × contract size once; `CumNotional` sums base quantity × the adjusted native price; `FeePresent` stays false so the member cost binding charges its fee once with no second slippage.
The `match` record (full outcome, book levels, fills, digest) and its `consume` records commit in one transaction that rechecks capacity and saved level content; a failed commit exposes nothing.

**Visibility and absence.** Visibility is availability + response latency, checked for overflow; before it the venue answers `open` with no fill totals, then terminal with immutable fills (`<client>-f<n>`, event t, visible at the visibility time) and a `visible` record.
`NextVisibleMs() (int64, bool)` returns the earliest saved visibility after the clock, and `pairWakeBoundaries` adds it; hosts still schedule market deliveries and deadlines.
With no saved decision, absence needs continuous window coverage from at or before s through the deadline plus the recovery grace and `pairAbsenceReleasable` (strict `now > deadline + grace`); the venue then saves an `absence` record and answers `absent` with complete coverage; anything else stays `open`.
A clock earlier than the latest saved venue record answers `unknown`.

**Coordinator boundary.** The venue never reads a private position (`Position` refuses and counts the call) and the barrier never selects it.
Before each `Submit` or `Query` the coordinator hands the venue the member's native and unexplained journal rows (`noteNativeInventory`), and refuses an answer observed or visible after its own time.
A virtual protective or hold-age reduction takes the #1879 saved reference and width bound like every coordinator reduction; a missing or stale reference holds before reservation with `reduction_reference_unavailable`.
`pairVirtualConservation` compares, per member, virtual executions (hidden included) plus native journal rows with the book plus each action's executed minus applied quantity, and refuses an action that confirms more than the venue executed; a disagreement holds increases (`pairIncreaseHold`) with one alert while queries, settlement and native protection continue.
Native closes enter only through their journal rows; a native-booked action's applied quantity is never added.

**Contracts for later children (#1879, #1881, #1882, #1914, #1863).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairVirtualVenueConfig` | `Partition RiskPartition`, `RunTag`, `MemberID`, `Provider`, `AccountKey`, `Instrument string`, `Market pairVirtualMarket`, `AdverseBandBps float64`, `SubmitLatencyMs`, `ResponseLatencyMs int64` | built before admission; identity fields only |
| `newPairVirtualVenue` | `func newPairVirtualVenue(c pairVirtualVenueConfig) (*pairVirtualVenue, error)` | the only constructor |
| `attachPairVirtualVenues` | `func (bc *basketCoordinator) attachPairVirtualVenues() error` | once, after `LoadBaskets` and the clock, before `startupCheck`; the factory must return the same instances |
| `pairVirtualMarket` | `Events(ctx context.Context, key pairMemberKey, afterMs, throughMs int64) (pairVirtualMarketWindow, error)` | full available window, recorded order, no future receipt |
| `pairVirtualMarketWindow` | `Events []pairObservation`, `Coverage pairCoverage` | window coverage proves continuity only inside the carried windows' complete spans |
| `NextVisibleMs` | `func (v *pairVirtualVenue) NextVisibleMs() (int64, bool)` | earliest saved visibility after the clock |
| Client ids | `vp-<run_tag>-<member hash8>-<seq>` | allocation-only state is research state |
| Bound sources | `decision_evidence`, `reduction_reference` | an entry or a policy exit takes its slot's saved rounded bound evidence; protective and hold-age reductions take the #1879 width bound of their saved reference |
| Conservation | `pairVirtualConservation(rt) string` | native journal rows only; #1914 may change native applied quantities without changing it |

**Tests.** `TestBasketPairVirtualVenue*` are store-backed Go integration tests with a scripted recorded market; they spawn no Python and run locally (CI skips `^TestBasket`).

### Native emergency evidence and accounting (#1914)

Issue #1914 books a pair member's kill-switch and circuit-breaker closes only from confirmed evidence: a live-shaped member from the native close's venue fill records, a paper member at its own checked mark.
Native sends keep their authority; no missing reference, bound, reservation, receipt save or accounting record blocks one, and native requests carry no reference field.
It changes no deployed configuration, default, credential, promotion or live trading behaviour; production pair activation stays refused.

**Native evidence.** `close_hyperliquid_position.py` and `close_okx_position.py` add `close.evidence` (`v` 1, `venue_order_ids`, `accepted_at_ms`, `executed_native_qty`, `fills` in the `pair_orders.fill_record` shape, `complete`, `detail`) built by `shared_tools/native_close_evidence.py`; the ordinary `fill` fields keep their meaning.
Hyperliquid reuses the single user-fill read of the close order; OKX reads each close order (`fetch_order_info`) and its trades and retries a few times while an order is not terminal or its fills are short; `market_close_orders` returns every order a close sent, and `market_close` returns the first as before.
`pairNativeEvidenceResult` maps evidence once: base = native × the admitted contract size, notional = native notional × contract size, explicit fees (zero and rebates included) become fee records, and the accepted time is a venue time (the order time, else the first fill time), never a local time.
A requested size, an order price, an exit code or a reported fill without fill records books nothing and is `unknown`; evidence with no fill records is `no_fill` only when it is complete (every order terminal, zero executed), else `unknown`; a remainder the venue reports executed without records adds `native_fill_unconfirmed`; a transport error that still returns fill records keeps them.
`OKXCloseFill.BaseSz` is set only by the pair-aware closer; `parseOKXCloseOutput` zeroes a script value, and `TotalSz` and `--sz` stay in contracts.

**Native actions.** A native action's purpose (`pair_kill_switch`, or `pair_operator` with recovery-note trigger `circuit_breaker`) alone makes it native (`basketAction.pairNative`); reservation also needs a strictly decoded note with `native_booked`.
The wrappers match only live-partition pair runtimes, the circuit router also matches the strategy id, and a peer strategy holding the coin in the partition makes the fill `native_receipt_ambiguous` and books nothing for the member.
A confirmed fill moves the action to `settling`, opens recovery explicitly and is booked by `settlePairActionLive` with the scoped fill identity `partition|member|provider|account|instrument|native|fill id`, the frozen settlement conversion at the venue fill time and the confirmed fee seam; book, trade, ledger, applied totals and progress save together, and a progress-only write never saves `committed` for any action.
Status `held` (added by a one-time table rebuild of `basket_member_actions`) ends a native action whose fill is unknown or whose fee evidence stays incomplete after booking; it is terminal, never pending and never counts as booked.
`idx_pair_actions_one_protective` counts native rows only while `reserved`, `submitting` or `unresolved`, so a settling native accounting row never blocks a later native close or a coordinator reduction.

**Receipts.** When no action can be reserved or saved (busy entry lock, pending protective reduction, failed write), a confirmed fill becomes a sealed `pairNativeReceipt` in `pairProgress.NativeReceipts` with obligation `native_receipt_unattached`; a receipt kept only in memory raises one CRITICAL alert and every later progress write retries its save.
`attachPairNativeReceipts` runs on every non-save-blocked step: one transaction (`StateStore.AttachPairNativeReceipt`) refuses a fill already saved on a native row of the member (an identical repeat drops the receipt, a different one holds as `native_receipt_conflict`), inserts a `settling` accounting action and removes the receipt; no order is sent.
A process loss before a receipt save leaves a visible held result (the unrecorded-close recovery and the venue position barrier); no unsaved record is described as durable.
Committed native rows from before #1914 with confirmed fills and no ledger load with `native_legacy_unverified` and are never booked again.

**Paper and generic closes.** `closeVirtualPositionInner` never books a pair member: it sets the strategy's pending close under `PlatformPendingClosePair` (`"pair"`), which survives the kill-switch pending reset, cooldown expiry and restart.
`stepPairEmergency` books a paper member only at its own `usdMarkAt` value: it saves a staged `pairPaperClose` (member key, close time, USD price, native mark, price currency, conversion record, view generation and evidence hash) before the book change, books through `bookVirtualClose` inside the paper book owner (native-origin inventory rows, so `pairVirtualConservation` is unchanged), and a failed save retries the same staged price: the record stays `staged` in memory and in every progress write until a book save commits (each member's commit, pending-close clear and obligation clear is applied only after the member loop, just before the book save, and a failed save restores them; the flat-book commit waits while `needsDurable` is set); a missing, stale or substituted mark keeps the book with `emergency_close_pending`.
The kill-switch owners (`forceCloseKillSwitchPositions`, `forceCloseSettledPositions`, `forceClosePaperScopePositions`) pass trigger `kill_switch` explicitly (`forceCloseAllPositionsFor`, `closeVirtualPositionAtMarkFor`); breaker closes pass `circuit_breaker`; ordinary close reasons are unchanged.
A live-shaped member waits for native evidence, and its pending close clears only when its book is flat and no native accounting is open.
`applyHyperliquidCircuitCloseFill` refuses a pair member, and Hyperliquid account sync skips pair members.
`RiskReasonPairValuationStale` now also sets the native circuit pending and the pair pending close; it still books nothing without a checked price.

**Completion.** The paper kill switch sets `KillSwitchCloseApplied` and sends its close-complete message only when `pairEmergencyPendingFor` reports no member; the live venue-flat branch suppresses the owner-less auto-reset and appends `pairKillSwitchAccountingNote` while it reports any.
`ResetPortfolioKillSwitchManual` stays the operator's terminal path and is not gated; it clears the latch and leaves every pair obligation, so the research result stays incomplete. Since #1927 these five kinds end only through a saved venue-history conclusion or an audited operator decision (§ Native fill resolution (#1927) below), before #1863 activates a live pair.
`pairIncompleteEvidence` reports, after the policy timeout, native and emergency obligations, settlement and fee obligations of native actions, unattached receipts and staged paper closes.
Funding inventory treats native actions like every action and, through `from_ms` on native unknown, unconfirmed and ambiguous obligations and unattached receipts, never advances past an unknown native fill time.
Pair progress is version 2 (version 1 loads; an older binary refuses version 2), and `storage-inspect` and startup report receipts and staged closes.

**Contracts for later children (#1881, #1882, #1863).**

| Name | Go signature or type | Rule |
|---|---|---|
| `nativeCloseEvidence` | `V int`, `VenueOrderIDs []string`, `AcceptedAtMs int64`, `ExecutedNativeQty string`, `Fills []pairScriptFill`, `Complete bool`, `Detail string` (`close.evidence`) | the only native fill authority; native units |
| `pairNativeEvidenceResult` | `func pairNativeEvidenceResult(m pairContractMember, ev *nativeCloseEvidence, reportedFill, alreadyFlat bool, closeErr error, nowMs int64) pairNativeResult` | base and notional × contract size once; no records = `unknown` |
| `pairKillSwitchHLCloser`, `pairKillSwitchOKXCloser`, `pairCircuitHLCloser`, `pairCircuitOKXCloser` | `func (bc *basketCoordinator) pairCircuitOKXCloser(stratID string, inner OKXLiveCloser) OKXLiveCloser` (same shape) | live partition only; pass through without a pair runtime |
| `pairCircuitHLCloserFor`, `pairCircuitOKXCloserFor` | `func pairCircuitOKXCloserFor(state *AppState, stratID string, inner OKXLiveCloser) OKXLiveCloser` | the circuit owners route each job through `AppState.pairViews` |
| `pairNativeReceipt` | `ID`, `Key`, `MemberID`, `Purpose`, `Trigger`, `OrderSide`, `PositionSide`, `ExecutedQty`, `Unconfirmed`, `Outcome`, `CreatedAtMs`, `Hash` (snake_case) | saved in `pairProgress.NativeReceipts`; attach only through `AttachPairNativeReceipt` |
| `pairPaperClose` | `MemberID`, `Key`, `Symbol`, `PositionID`, `Side`, `Qty`, `Trigger`, `CloseAtMs`, `PriceUSD`, `NativeMark`, `PriceCurrency`, `Source`, `Generation`, `EvidenceHash`, `Conversion`, `Status` (`staged`, `committed`), `CommittedAtMs` | saved in `pairProgress.PaperCloses` before the book change; retried unchanged |
| `PlatformPendingClosePair` | `"pair"` key of `RiskState.PendingCircuitCloses` | set by every generic virtual close of a pair member; cleared only by a committed flat member |
| `pairEmergencyPendingFor` | `func pairEmergencyPendingFor(state *AppState, part RiskPartition) []string` | hosts gate close-complete messages and owner-less resets on an empty result |
| `basketActionHeld` | `"held"` | terminal, not pending, never booked |
| Obligations | `native_fill_unknown`, `native_fill_unconfirmed`, `native_receipt_unattached`, `native_receipt_conflict`, `native_receipt_ambiguous`, `native_legacy_unverified`, `emergency_close_pending` | keep accounting incomplete; only a committed booking or attachment clears the receipt and emergency kinds |

Rules: #1881 and #1882 call `preparePairRisk` before risk so a paper emergency close has a fresh own mark, run `bc.step` after the paper kill switch, and report `pairIncompleteEvidence`; they never book a pair member through a generic close owner.
#1863 keeps the coordinator as the only pair book writer.

**Tests.** `TestBasketPairNativeEmergency*` and `TestBasketPairPaperEmergency*` (untagged, local; CI skips `^TestBasket`), the extended `TestBasketPairKillSwitch*`, `TestBasketMemberViewIncompleteValuationEscalatesAfterTheDeferral` and `TestBasketPairVirtualVenueNativeBookChangesCountOnce`, and `TestBasketPairEmergencyReal*` (`pyintegration`: the real close scripts with substituted venue responses and refused network and signing).

### Native fill resolution (#1927)

Issue #1927 ends the five native obligations of #1914 through a saved venue-history conclusion or an audited operator decision, and books newly confirmed fills once through the existing settlement ledger.
It changes no deployed configuration, default, credential, promotion or live trading behaviour; production pair dispatch stays refused, and a refused pair runs no recovery read and applies no decision.

**Attempts.** `finishPairNative` saves one `pairNativeAttempt` per native send in `pairProgress.NativeAttempts`: a deterministic id `pna-…` (pair, member key, purpose, trigger, send time and `NativeAttemptSeq`), the member key, provider, account, instrument, contract fingerprint, purpose, trigger, sides, the linked action or receipt, the ambiguity reason, the send, end and acceptance-deadline times, every valid venue order id (sorted, at most 8), `orders_complete`, the raw close evidence, the close error, the provenance (`close_evidence`, `close_error`, `close_no_evidence`, `operator_orders`), the evidence revision and per-attempt retry state (`retry_count`, `retry_at_ms`, `last_error`, `last_read_at_ms`; never `pairProgress.Retries`).
An attempt is kept only while a native obligation or a receipt names it, so progress stays bounded; resolution audits live in their own table.
`close.evidence` adds `orders_complete`: Hyperliquid sets it once the close order id is known; OKX sets it when every close order returned, and `market_close_orders` raises `OKXCloseOrdersError` carrying the orders already sent when a later order fails, so `close_okx_position.py` reports their ids with `orders_complete` false.
A missing field means complete only for a close that returned without an error.
Unknown and ambiguous outcomes keep the order ids; an ambiguous close keeps its whole receipt in the attempt.
A new action-less `native_fill_unknown` and every new `native_receipt_ambiguous` obligation is keyed by its attempt id, so two closes never share one obligation or alert.
An action-linked obligation keeps the action id; an unattached confirmed receipt keeps its fill-derived `pnr-…` id.
An obligation saved before #1927 with no attempt has no provable identity: a `pnr-…` action-less or ambiguous one may cover several closes and only a write-off or acknowledgment ends it, and an action-linked one needs operator order ids; no order id is ever inferred from time, size or the unused reserved client id.

**History route.** `--pair-native-history-json` on `check_hyperliquid.py` and `check_okx.py` takes a strict version-1 request (`pair_id`, `partition`, `member_id`, `provider`, `account_key`, `instrument`, `attempt_id` `pn[ao]-` plus 32 hex, `order_side`, 1..8 `venue_order_ids`, `from_ms`, `through_ms`, `revision`; unknown fields, duplicate keys and malformed ids are refused) and prints `native_history` (version 1: echo, `status` `complete` or `incomplete`, `detail`, per-order state, terminal flag, side, instrument, executed native quantity and acceptance time, fill-history coverage and version-1 evidence).
Probe mode exits before any adapter, signing or read; the route places, cancels or changes nothing, and the ordinary pair order and query routes still refuse native purposes.
Hyperliquid reads `query_order_by_oid` for each order and one complete `scan_recovery_fills` window from `from_ms` (truncated, capped or stalled history is incomplete) and matches fills by oid; OKX reads `fetch_order_info` and `fetch_order_trades` per `ordId` and treats a full page of 100 trades as clipped.
Both require the credentials to answer for the request account, every order terminal and the fill records to sum exactly to the executed size; `pairCheckNativeHistory` then rechecks the echo, the order set, each order's side, instrument and acceptance inside the window (send time minus 10 s through the acceptance deadline plus 10 s), the executed sum and each fill's order, side and time.
An empty or incomplete answer never proves a zero fill: a zero fill needs every order terminal with zero executed in a complete answer.

**Recovery reads.** `stepPairNativeRecovery` runs inside `stepPair` after the durable-settlement retry and before receipt attachment, and only when the step is not save-blocked and no settlement waits to become durable.
It applies staged operator decisions first, then reads at most one due attempt per pair per step: the attempt must name every order (`orders_complete`), be unambiguous, back a `native_fill_unknown` or `native_fill_unconfirmed` obligation, belong to a member with no native send, read or staged decision in flight, and its action must be `held` or `unresolved`, or `settling` only while its attempt holds a staged resolution for it and the action is not complete (a recovered booking whose quantity waits for fee evidence).
Under `mu` it reserves a member read token and captures the obligation revision and the member's native-send generation, reads through `bc.unlocked` with a 20 s timeout, and discards the answer when the token, the generation, the obligation or its revision changed.
The reader is `basketCoordinator.pairNativeHistory` when a test sets it, else the live-shaped venue's `NativeHistory`; paper, replay and virtual venues have none and never read private history.
A failed, incomplete, conflicting or stale answer books nothing, clears nothing and sends nothing; it saves the diagnostic and the next retry (`retry_initial_ms` doubling up to `retry_max_ms`), and wake boundaries include each due retry.

**Accounting.** A complete answer maps through `pairNativeEvidenceResult` once.
For an action it keeps each already accepted fill's saved `pairVenueFill` (so `pairFillsImmutable` passes), runs `pairOutcomeError`, the cross-action fill check, the decided-fill check, `pairEvaluateExecution` and `pairEvaluateFees` on a copy, and refuses any change to accepted fills, a fee conflict, new fills after final fees, an overfill or a legacy action.
`StateStore.CommitPairNative` then moves the row from its saved status (`held`, `unresolved`, or the waiting `settling` above) to `settling` with a dedicated guarded update (exact action id, member, native purpose, request, client id and saved fill set, plus a cross-row native fill-id check) and saves the new evidence and a staged resolution (`pairNativeAttempt.Pending`) in one transaction; generic terminal-row guards are unchanged and a failed transaction restores memory.
The settlement owner books only the ledger delta (new fills, or a fee-only correction); the resolution row and the obligation removal are written only inside a save that writes the member book and leaves the action `committed` and complete (`writePairProgressFinalizingTx`), and memory changes only after that commit (`applyPairNativeFinalized`).
A progress-only or subset save keeps the durable obligation and publishes no resolution, so a failed book save keeps every completion gate closed.
A proven zero fill commits `no_fill` (dedicated update requiring zero confirmed and applied quantity) with its resolution and no book change, and is refused for `native_fill_unconfirmed` or any action holding confirmed or booked fills.
An action-less attempt with confirmed fills becomes a sealed receipt whose `native_receipt_unattached` obligation replaces the unknown one in the same transaction; the receipt owner books it.

**Operator decisions.** `/go-trader-resolve-pair-native` (§ Discord Slash Commands) reaches the coordinator through `pairNativeOperatorRouter` on `AppState.pairViews`.
The handler checks owner and DM scope, then under `mu` the partition, member, kind, decision, current revision (`pairNativeObligationRevision`: SHA-256 prefix of the kind, member, id, start time, linked action status, quantities, fill, fee and ledger records, request hash, attempt id, revision and orders and receipt seal; detail rewrites and retry diagnostics never change it), a printable reason of at most 500 characters, at most 8 order ids, no in-flight native send or read, no committed resolution, no other staged decision and, for a write-off or acknowledgment, no staged recovered booking for the obligation (it ends the obligation through the ledger; a venue read can add its missing evidence, and `show` lists it); a DM `confirm` is required, and staging saves a `pairNativeOperatorRequest` (`pnq-…`) in progress or reports that nothing is staged.
The coordinator step is the only writer: it voids a request whose obligation ended, whose revision changed or after a newer native send for the member (the send keeps its authority), commits write-offs and acknowledgments, and runs at most one operator venue read per step through the same checks as recovery.
A newly staged request (or one loaded after a restart) wakes the coordinator once; a request that cannot apply yet (save blocked, settlement not durable, member busy or a failed commit) waits for the ordinary decision cadence instead of the 1-second floor.
`venue-read` is allowed for `native_fill_unknown` and `native_fill_unconfirmed`; the read covers the operator order ids together with every order id the attempt or action already saved (its order list, its raw close evidence and the action's order id), so a subset never proves a zero fill or settles the close; the 8-order limit applies to that merged set, an unreadable saved id refuses the read, operator order ids already listed by another attempt or saved as the venue order id of another pair action of the member are refused, a read whose fills another pair action of the member already accounts books nothing, and an action without an attempt gets an `operator_orders` attempt for its staged resolution.
The recovery and receipt-conversion transactions and receipt attachment check fill ids against every saved pair action row of the member, whatever its purpose and whether `prunePair` already dropped it from memory; the order-id refusal reads only in-memory actions, so the saved-fill check is the durable guard.
`native_receipt_conflict` and `native_receipt_ambiguous` take only a write-off or an acknowledgment, because no venue record can attribute a whole-coin fill shared with a peer; `native_legacy_unverified` takes only an acknowledgment or a write-off and is never booked again.
A write-off or acknowledgment changes no book, never asserts venue flatness and never changes the kill switch; an `unresolved` action becomes `held`, a conflicting receipt is dropped unbooked, and an obligation with a start time on a horizon kind adds `pairNativeFundingHold` (`NativeFundingHolds`), which `pairNativeUnknownFromMs` reads only while the hold is open, so funding inside its unknown interval stays incomplete.
A later exact venue position read can bound a hold: `pairReadPositions` with proofs enabled captures a token (mutation epoch, coin epoch, native-send generation, contract fingerprint and eligible open holds) before the unlocked read, then per member requires every `pairCheckVenuePosition` check, exact rational equality of venue base quantity (native times contract size) with the canonical book quantity, no unapplied fill, pending action, native or settlement obligation, unattached receipt, staged close or decision, pending recovered booking, native send or history read, no save block, no peer holding or configured for the coin, and the same token, eligibility and journal prefix after the read.
The proof (`pairNativeFundingProof`, canonical bytes with a hash, the sealed position, the contract bytes and fingerprint, exact quantities, the journal row count at acceptance and the ordered host-local read times) and the bound `ThroughMs` (the first receipt time) commit in the one progress transaction through `PersistBasketProgress`; a failed commit restores memory, reads the saved progress back, and keeps the hold open (with a volatile read-back retry when the read-back itself fails), and `checkSavedPairNativeBoundsTx` refuses any later progress write that removes, replaces or moves a saved bound or proof.
Load checks that journal anchor only through `journal_rows`, the prefix present when the proof was accepted; a later append, including one whose event time is at or before the bound, never refuses the load, and a prefix whose quantity differs from `BookBaseQty` still does.
Each funding event whose assessment interval intersects any hold interval is saved `unresolvable` (`native_decision_unproven`) with no charge and no funding obligation, with one alert per hold and no CRITICAL alert per event; an event wholly after a bound uses the ordinary inventory, coverage and settlement checks, coverage stretches and gap obligations subtract the native-unproven intervals, a hold never holds the lifecycle flat reset or the contract-change check, and previously saved unresolvable rows never change.
While any member has an open hold, `pairIncreaseHold` holds every pair increase until an exact venue position read bounds the hold; a bounded hold alone never blocks an entry, exits, queries, protection and emergency closes continue, and the write-off and acknowledgment confirmation states the bound release.
A live member whose book stays open keeps `emergency_close_pending` after a write-off; only booked fills (a venue read) flatten it.

**Audit and markers.** Every resolution is one immutable row in `pair_native_resolutions` (§ Storage Ownership) with mode `venue_history`, `zero_fill`, `operator_venue_read`, `write_off` or `acknowledge`, decided by `coordinator` or `operator`, the operator, reason, request id, revision, decision and commit times, the prior obligation, the attempt snapshot, the evidence, the booked quantity, fill ids, the funding hold start and `alert_status`.
The rows load into the runtime (never into progress), so the flat reset cannot drop them; `recoverPairNativeLegacy` and `endPairNativeAction` skip a resolved item, and receipt attachment holds a receipt carrying a written-off or acknowledged fill as `native_receipt_conflict` for review.
Each committed resolution is alerted once (operator decisions CRITICAL) and marked `dispatched`; a `queued` row after a restart is an uncertain delivery and is never resent.
`pairEmergencyPendingFor`, `pairIncompleteEvidence` (adds `native_funding_uncertain` per hold) and the lifecycle `accounted` test (staged decisions and staged resolutions, never holds) read the remaining state; another member's obligation, risk latches, venue-flat checks and pending actions are untouched.
Pair progress is version 3 only while `native_attempts`, `native_attempt_seq`, `native_funding_holds` or `native_requests` is present (else version 2 bytes are unchanged; versions 1 and 2 load), the flat reset carries all four, and they are recovery evidence for the wrong-file and removed-pair refusals; staged decisions and staged resolutions also refuse a contract change, a funding hold does not.
The offline pair replay exports `pair_native_resolutions`, reports each committed decision in `pair.native_decisions`, lists every hold in the ordered `pair.native_funding_holds`, and keeps a result with a write-off incomplete, using a saved proof offline with no private position read.

**Contracts for later children (#1882, #1883, #1863).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairNativeAttempt` | `ID`, `MemberID`, `Key`, `Provider`, `AccountKey`, `Instrument`, `ContractFP`, `Purpose`, `Trigger`, `OrderSide`, `PositionSide`, `ActionID`, `ReceiptID`, `Ambiguous`, `SentAtMs`, `EndedAtMs`, `DeadlineMs`, `VenueOrderIDs`, `OrdersComplete`, `Outcome`, `Evidence`, `CloseError`, `Provenance`, `Rev`, `RetryCount`, `RetryAtMs`, `LastError`, `LastReadAtMs`, `Pending` (snake_case) | in `pairProgress.NativeAttempts`; kept only while an obligation or receipt names it |
| `pairNativeResolution` | `ID` (`pnz-…`), `Partition`, `PairID`, `MemberID`, `Kind`, `ObligationID`, `AttemptID`, `ActionID`, `ReceiptID`, `Mode`, `DecidedBy`, `Operator`, `RequestID`, `Reason`, `Revision`, `DecidedAtMs`, `CommittedAtMs`, `Prior`, `Attempt`, `Evidence`, `FillIDs`, `BookedQty`, `FundingHoldFromMs` | one row per obligation in `pair_native_resolutions`; immutable |
| `pairNativeHistoryRequest` / `pairNativeHistoryResult` | request fields above; `Complete`, `Detail`, `ObservedAtMs`, `Orders []pairNativeHistoryOrder`, `Evidence nativeCloseEvidence` | `--pair-native-history-json`; `pairCheckNativeHistory` rechecks every script answer |
| `pairNativeHistoryReader` | `func(ctx context.Context, m pairContractMember, req pairNativeHistoryRequest) (pairNativeHistoryResult, error)` on `basketCoordinator.pairNativeHistory` | nil in production (the live-shaped venue serves); never set on paper or replay hosts |
| `pairNativeOperatorRouter` | `pairNativeOperator(cmd pairNativeOperatorCommand, stage bool) (string, bool)` on `AppState.pairViews` | the only operator entry; stages, the step applies |
| `pairNativeObligationRevision` | `func pairNativeObligationRevision(rt *basketRuntime, ob pairObligation) string` | 16 hex; the operator's expected version |
| `pairNativeUnknownFromMs` | unchanged signature; adds open `NativeFundingHolds` only | funding consumers keep calling it; bounded holds act only through their interval |
| `pairNativeFundingProof` | `V`, hold identity, `ContractFP`, `ContractJSON`, sealed `Position`, `VenueBaseQty`, `BookBaseQty`, `JournalRows`, `RequestStartMs`, `AcceptedAtMs`, `TimeSource`, `ProofHash` | canonical proof bytes stored in the hold; `ThroughMs` is the position first receipt time; `JournalRows` is the inventory prefix checked at load |
| `pairCheckNativeFundingProof` | `func pairCheckNativeFundingProof(hold pairNativeFundingHold, proof pairNativeFundingProof) error` | validates hash, saved contract, position, exact quantities, identity and ordered times without a runtime |
| `checkSavedPairNativeBoundsTx` | `func checkSavedPairNativeBoundsTx(tx *sql.Tx, rt *basketRuntime, next pairProgress) error` | every progress writer refuses to remove, replace or move a saved bound or proof |
| `pairProgressVersionNative` | `3` | written only with native recovery fields |
| `nativeCloseEvidence.OrdersComplete` | `*bool` `orders_complete` | missing = complete only without a close error |

Rules: #1882 keeps paper and replay hosts without a history reader, reports `pair.native_decisions`, treats a write-off as incomplete, and carries the four native progress fields through any new progress literal.
#1883 never resolves a native obligation itself and reads the audit table only.
#1863 enables live pairs only with this resolution path in place and keeps the coordinator as the only pair book writer.

**Tests.** `TestBasketPairNativeRecovery*` and `TestBasketPairNativeOperator*` (untagged, local, CI skips `^TestBasket`: real coordinator and SQLite with a scripted history reader; rate limit then recovery for both venues, confirmed partial then later fills, refused answers, zero fill, identity across restart, action-less receipts, a failed book save, operator write-off, refusals, operator venue read, legacy acknowledgment across the flat reset, and staging racing a step) and `TestBasketPairNativeRecoveryReal*` (`pyintegration`: the real close and check scripts with substituted venue responses, refused network and signing, and a reader that maps the fixture's synthetic accounts to the venue account format).

### Offline basket replay (#1791)

`go-trader basket-replay --spec <step-script|-> [--max-steps N] [--fault exit-after-submit:N]` runs the real coordinator above offline (a version-2 specification runs a research pair instead, Offline pair replay below); `backtest/basket_replay.py` is its driver (docs/backtesting-registry.md).
It is research-only, live use stays refused, and it adds no second coordinator, ranker, member-order authority or storage owner.

**Contract.** Input is one versioned step script (`go-trader.basket-replay` v1, unknown fields refused): `mode` `new` or `resume`, `run` (`run_id`, an absolute real `state_dir`, `source` provenance), the `basket` block, `members` (`id`, `coin`, `timeframe` and an allowlisted `config`: capital, leverage, drawdown and circuit-breaker settings, close strategy, stops, margin mode, pause, ATR method), optional `portfolio_risk`, `target_input` (`ranker` or `baseline` with frozen weights per deadline), optional `cost_policy`, `lot_metadata`, gap-free closed `candles` per `COIN|timeframe`, optional `funding` coverage per coin, and ordered `steps` (`at`, `marks`, scripted `venue` and `queries` outcomes, `save_failure`).
Output is one JSON document on standard output (logs go to standard error): status `complete`, `incomplete`, `stopped`, `refused` or `error`, run fingerprints and cursor, the capital, clock and event-order models, the applied and default parity cost policies, one record per applied step from the start of the run (touched rebalances with targets, holds, exceptions and deviations; touched actions, including holding-horizon closes, with both endpoint bound readings; member lifecycle (entry, expiry, state, attempt, reopen suppression), cash, books, stops, risk state, entry allowance and funding progress; partition latch and daily-loss state; exposure and basket equity), every booked trade, coverage gaps and metadata-fetch refusals.
The output also carries the run-wide venue event log (`venue_events`, #1790): every submit and query call in database event order with its step, client order id, action id, scripted flag, script index and outcome, so a caller can compare the chronological call sequence (the sorted step records do not prove submission order).
Member records also carry the lifecycle failure count, retry time and retained alert keys.
Each step record reads the basket rebalance and action rows saved at that step from the run state file as well, so a rebalance completion or an action that a durable save pruned within the same step (for example a holding-horizon close sent at a new rank deadline) still appears; rows still in memory keep their in-memory state.
A resumed run reads the earlier steps' records and lot refusal counts back from the run state file, so its steps, gaps and refusal count cover every applied step, whichever process applied it.
Exit codes: 0 run applied, 1 error, 2 usage, 3 refused, 75 injected fault.

**Isolation.** The adapter dispatches before any daemon setup, builds its own paper config, opens no notifier, feed or deployed config, and unsets credential environment variables.
Before any state is opened it refuses a step script that is malformed or invalid (including a step earlier than the one before it, a member field that sets routing, mode or storage, and an unknown protocol version), a state directory under a deployed path or behind a symbolic link, new-run mode on any existing path, and resume of a directory the adapter did not create for this run (manifest, single replay run record, owner, entries and every fingerprint must match).
It takes the state file lock, and the scheduler's own storage inspection refuses a file that holds a replay run.

**Clock and inputs.** One simulated clock (`tradingNow`) reaches the booking, funding, risk, decision, action and lifecycle owners; ordinary callers keep the system clock.
The coordinator's holding-horizon expiry, lifecycle, reopen suppression and basket row times take the step time, and the per-book clock (`strategyBookNow`) falls back to the same clock, so a basket with `holding_horizon_seconds` expires unchanged on simulated time, also between rank deadlines and across a resume.
Rank admission and collection use simulated time (a step at or after the window end starts no job, a result collected at or after it holds), while the ranker context and process timeout use the remaining simulated window measured on the system clock, and output from a cancelled or timed-out run never freezes targets.
Frames are cut at the step time with the forming bar open-only; marks are fresh at their sealed time.
Frozen lot metadata serves both target freezing and paper booking and never fetches; a coin absent from it holds and counts as a fetch refusal.
Funding settles through the paper funding owner against the frozen records up to the step time; a gap, held record or unsettled exposure leaves the run incomplete.

**Cycle order.** Each step runs funding accounting, partition risk with the paper kill switch (kept latched, as with an owner configured), the per-member circuit-breaker check and the shared Hyperliquid perps management step (`runHyperliquidPerpsStep`, the same function the scheduler runs for every Hyperliquid perps strategy), then the coordinator step, then one full save that also commits the cursor.
After each durable save the step records built since the previous durable save are written to the run state file.
A step whose save failed with no later durable save, a stored cursor behind the applied steps, or an applied step with no stored record (a stop between the save and the record write) leaves the run incomplete.
A step between deadlines still runs management, reconciliation and frozen-target execution.

**Costs.** Default runs book the frozen paper model (version 1, taker fees on paper take-profit tiers).
A `cost_policy` (`taker_fee_pct`, `slippage_pct`, `fill_role: taker_all`, `spread: inside_slippage`) that differs from it is passed into the same open, add, partial-close, full-close, stop and kill-switch booking owners and the prospective margin gate, once per confirmed fill, and stamps version 1001 with the run's policy id.
A changed policy refuses resume.

**Baseline.** `target_input.mode: baseline` freezes declared weights per deadline through the same target freezing, transition, risk, settlement and recovery owners; listing eligibility, finite weights, gross, the declared net bound (`max_transition_net_usd`) and the per-coin cap are checked, and only the long and short side requirement of production ranker admission is dropped.
Each weight key must be a rebalance deadline that some step reaches inside the evaluation window, and two keys must not name one instant; otherwise the step script is refused.
The path runs only inside the adapter.

**Limits.** Fill role is taker for every fill; spread is inside slippage; regime labels are not frozen, so regime-keyed stops and unified regime closes are refused; venue-absent funding hours inside the window leave the run incomplete; `exit-after-submit` is a controlled restart fault for tests.
The adapter builds each step's market snapshot in process and never writes or reads a sealed feed document, so the seal version (including version 4 trade-flow, version 5 macro-event, version 6 volume-profile, version 7 l2_book and version 8 premium seals) does not apply; it freezes no open-interest, trade-flow, l2_book or premium observations and no macro calendar or quote records, and a configuration that needs any of them is refused before any state is opened.

### Offline pair replay (#1881)

`go-trader basket-replay --spec <spec|-> [--max-steps N] [--fault <kind>:N]` dispatches on the specification `version`: version 1 is the basket replay above, unchanged in input, output and exit meanings, and version 2 runs one research pair (Research pairs above) offline through the real coordinator, policy runner, member checks, virtual venues, funding, risk and booking owners (`scheduler/basket_replay_pair*.go`).
It adds no second coordinator, decision writer, policy caller, book writer or storage owner, and it changes no deployed configuration, default, credential, promotion or live trading behaviour.

**Specification (version 2).** Fields, unknown fields refused: `protocol` (`go-trader.basket-replay`), `version` 2, `mode` (`new` or `resume`), `run` (`run_id`, an absolute clean `state_dir`, `source` provenance), `research_config` (one research configuration object with the sole `pairs` entry and its two member strategies), `input_manifest` (`root`, an absolute directory outside the run directory, and `objects`), `scenario` and `bounds`.
`research_config` must not set `db_file`, `log_dir`, `paper_db_file`, `paper_sources`, `market_feed`, `egress` or `baskets`; the adapter sets `db_file` to `<state_dir>/replay-state.db`, `log_dir` to `<state_dir>/logs` and `market_feed` to `websocket` (management frames reach the Hyperliquid check on stdin), so every storage file lies inside the claimed directory.
Each object is `{kind, member_id, path, sha256, size_bytes}` with kind `evidence` (the canonical `pairEvidenceDocument`), `funding` (the canonical `pairFundingDocument` v1, or since #1934 a `pairFundingDocumentV2` whose policy supplies the research rule and whose `from_ms` and `drain_through_ms` must equal the lifetime start and end; no recording or reader is needed, and the selected evidence and funding amount equal the recorded run while only a sealed recording playback claims the full comparison hash), `quotes` (a public member's recorded snapshots `{v: 1, member_id, records: [{receipt_ms, payload}]}`, payload as base64 source bytes in receipt order), `books` (a sealed Hyperliquid member's recorded book stream `{v: 1, member_id, instance, coverage_from_ms, coverage_through_ms, records: [{event_ms, recv_ms, bids, asks}], markers}`) or `candles` (`{v: 1, member_id, timeframe, rows}` gap-free closed bars).
`scenario` holds `submit_latency_ms`, `response_latency_ms` (the virtual venue latencies), `receipt_model` (`recorded_first_receipt`: every record keeps its recorded first receipt) and `venue_failures` (a list, empty for none): `submit_transport_error` (`purpose`, `nth`), `policy_process_failure` (`at`), `management_check_failure` (`member_id`, `at`) and `save_failure` (`at`); each applies at most once per run and is recorded in `basket_replay_failures` so a resume never repeats it; a finished run with a declared failure that never fired (an `at` that is no step or management time, an `nth` past the submits of that purpose) lists it in `incomplete_reasons` and is not mechanically complete.
`bounds` holds the inclusive `start` and `end` and a finite `max_steps`; the lifetime string `<start_ms>..<end_ms>/<max_steps>` is frozen in the run record and the manifest.
The input fingerprint is the SHA-256 of the specification without `mode`, `run.state_dir` and `input_manifest.root`; the run fingerprint binds protocol 2, run id, source (provenance, adapter `Version` and `SourceCommit`), input and the portable configuration; the manifest also binds the contract fingerprint, the evidence and funding digests, the funding binding, the object set, the lifetime, the scenario, the event order and the administrative field list.

**Validation and claim.** Every object is read, sized and hashed, and every structural check runs before any directory exists; the recorded quote source validates its records against the admitted contract through `pairQuoteSourceErrors` (`pairContractBinder.bindContract`, called once per contract and cached), so a payload that does not derive refuses inside admission before the claim.
Research admission (`newPairResearchAdmission`) is the only directory claim: a new run calls `os.Mkdir(state_dir, 0700)`, creates `pair-research.json` with `O_CREATE|O_EXCL` and syncs the file and the directory, so an existing path (an empty directory, a file or a symbolic link), a missing parent or a deployed prefix refuses and a losing concurrent claim writes nothing into the winner's directory.
The replay then creates `logs`, opens the store, inserts its run record, loads the state and commits the startup bindings, and only then writes `replay-manifest.json` (`O_EXCL`, synced); a directory without that manifest is an unfinished initialization that resume refuses.
Resume accepts only the replay-created entries (`replay-state.db` and its companions, `replay-manifest.json`, `pair-research.json`, `logs`), compares the manifest before admission and the contract, funding binding and run record read-only after it, and only then opens the store writable.

**Startup.** Each member venue is built with `newPairVirtualVenue` before admission (the member partition, its synthetic account, run tag = run id, the policy `adverse_band_bps` when declared, the scenario latencies) and the venue factory returns those instances throughout.
After the store opens: `LoadStateWithStore`, `LoadEntryGuards`, `LoadBaskets`, `ValidateStateWithPairOwner`, the coordinator with `bc.pairClock` = the simulated clock, the canonical policy runner wrapper and the replay hooks, `attachPairVirtualVenues`, `startupCheck`.
A new run then commits the books, contract, `CostEvidence` and `FundingBinding` through `SavePartition`, reads `pairCostBindings`, refuses an absent or incomplete map (model version 1001, a full `pair-cost-` id, rates equal to the contract member cost policy) and freezes it into `basket_replay_runs.member_costs_json` and the manifest before any management, allocation or execution.
A resume compares the loaded bindings and the saved cost evidence with the manifest and checks every member trade stamp (execution rows carry the member binding, funding rows the process stamp) before trading.
The startup refuses (exit 3) when a runtime file is missing or a probe fails: the Hyperliquid market-stdin pair argv (`hyperliquidMarketCheckProbeArgv`), the OKX pair management argv (`okxPairManagementProbeArgv`, with `--pair-member-json` and `--management-stdin`) and `run_pair_policy.py --probe-only`; `probeCheckScripts` also registers the OKX vector for every `check_okx.py`, so startup and `go-trader probe` reach it.

**Sources.** Public members read the newest recorded snapshot whose first receipt is at or before the read time, re-derived from its payload (`pairObservationFromPayload`) with its recorded receipt; nothing substitutes current data.
A sealed Hyperliquid member's seal is rebuilt for each `PairSnapshotFor` call from the recorded book stream received by the read time through `freezeBook` (readiness included) for every `pairFeedBookNeeds` key, with a printable `EvaluationID` `replay/<run>/seal/<at>`, generation 1 equal to `PairGeneration()` and seal time equal to the read time; `archive` members are refused in version 2.
The virtual market (`pairVirtualMarket`) returns, clamped to the simulated clock, every recorded record with `afterMs < venue time <= throughMs` and first receipt `<= throughMs` in recorded order; point sources carry incomplete coverage, and a book stream's window coverage is complete only from its declared start, through its declared end, without markers or receipt gaps above 10000 ms, so end of input never proves absence.
Management frames are the frozen closed candles cut at the cycle time; the Hyperliquid frame adds one open-only row, the OKX input (`pairManagementInput`) carries closed candles only and the member's own visible native mid.

**Host.** One cycle at logical time T: evidence delivery (the visible prefix), `stepPairFundingDue` (the coordinator entry point that takes `mu` and calls `stepPairFunding` with its inventory, visibility, save-block and pending checks; `stepPair` keeps its later call), `preparePairRisk`, partition risk through `measureScopeCycleRiskMarked` with `pairMarksLocked` under one `RLock`, `applyScopeCycleRisk` and `applyPaperKillSwitchCycle` under `Lock`, then on a management cycle `beginCycle` and one risk check plus one native management run per member (Hyperliquid through `runHyperliquidPerpsStep`, OKX through `checkStrategyRiskWithView` and `runOKXStep` with `okxPairManagementCheck`), then `bc.step`, then the final `SaveAll`.
A save-blocked cycle skips risk and management and steps the coordinator with `SaveBlocked`.
The next wake is the earliest of the management deadline (every `interval_seconds` from the start), record deliveries, evidence eligibility boundaries, funding visibility (`NextVisibleMs`), venue visibility, candle closes and the coordinator `wakeDelay` from fresh time; a zero delay runs again at T at most three times in a row.
The run finishes when the next wake is after `end`; exhausting `max_steps` or `--max-steps` stops it unfinished.

**Checkpoints.** Before any work the cycle saves a pending `basket_replay_steps` row (step id, time, ordinal, visible-prefix hash, the host state before the cycle) and points `basket_replay_runs.pending_*` at it.
After `buildPairEnvelope` succeeds the coordinator hook writes the checked collection (run, pair, contract, slot, original start, cutoff and deadline, envelope bytes and fingerprint, seal identity, generation, input object hashes) into the pending row before `freezePairEnvelope`.
After risk and management a `SaveAll` commits the books, risk rows and the coordinator checkpoint (host state after management, the management flag and the partition risk summary) in one transaction (phase `coordinator`).
The final `SaveAll` commits the books, the completed step record, its result hash and the cursor advance (`steps_applied`, `cursor_*`, the virtual ledger count as `cursor_binding_seq`) in one transaction.
The save hook writes only these two markers: internal coordinator saves inside `bc.step` write no replay row, so they never advance the cursor or the phase.
The host state is the management cycle, the next management deadline, the last pair step time, the valuation publication counters (`pairViewGen`, `pairPrepSeq`), the management observations and the echo failures.

**Resume table.**

| Saved state at restart | Restored | Rerun at the pending time | Skipped |
|---|---|---|---|
| cursor complete, no pending row | host state of the last step record, next wake from it | the next cycle | nothing |
| pending, phase `pending` | host state before the cycle | the whole cycle (funding is idempotent by event identity) | nothing |
| pending, phase `coordinator` | the coordinator checkpoint (cycle, observations, deadline, risk summary); the counters before preparation | `preparePairRisk`, `bc.step`, final save | funding, risk measurement and application, the kill switch, `beginCycle`, member management |
| pending with a saved collection and the slot still `reserved` | before `recoverInterrupted`: verified with `verifyPairEnvelope` on the saved bytes and original times, then `recheckPairSlot` and `freezePairEnvelope` under `bc.mu` after attachment and `startupCheck` | as the pending phase says; the slot is decided once from its frozen envelope | collection and reservation of that slot |

A saved collection for another run, contract, slot or input object set, a slot frozen with other evidence or a slot the file never reserved refuses the resume; ordinary recovery keeps its hold rule for every other reserved slot.
The valuation view starts empty and is rebuilt through `preparePairRisk`; the restored counters keep the publication sequence, and a first resumed view without a valid mark for a member removes the equality claim of that run (its last-mark substitution and `MarkLostAtMs` are process memory).

**Faults.** `--fault` accepts `exit-after-submit:N` only for version 1 and `exit-after-collection`, `exit-after-management`, `exit-after-envelope-freeze`, `exit-after-acceptance`, `exit-after-book-write` and `exit-after-book-commit` only for version 2; a wrong pairing or an unknown kind is a usage error (exit 2).
N counts occurrences in the current process: collections recorded, member management passes (`exit-after-management` fires after the pass and before the coordinator checkpoint save, so the resume reruns the whole cycle), envelopes frozen, submits answered with a venue acceptance, and the cycle's final save of the run file (`exit-after-book-write` fires inside `storeCommitHook` after the writes and before the commit; `exit-after-book-commit` after the commit returns).
Each prints `fault <kind>:<N>` on standard error and exits 75.

**Process failures.** The policy runner wrapper records a runner error, an ended context and unreadable output, and every member echo failure after management is recorded, each in `basket_replay_failures` immediately (stage, kind, step, slot or member, cause), before the coordinator freezes its hold; those rows keep the run mechanically incomplete after any resume.
A valid economic hold is a complete policy outcome; scenario rows for transport and save failures are markers only.

**Completion.** `mechanically_complete` needs a finished bounded run, the durable cursor equal to the completed steps with no pending row, no persisted policy or native-check failure, no declared scenario failure left unapplied, passing raw checks (step result hashes, virtual ledger chains, applied funding event decoding, frozen slot envelopes, member trade stamps), no `pairIncompleteEvidence`, no open pair obligation, no pending action, no confirmed but unapplied fill (`held` actions are terminal), no reserved or undecided slot, no non-durable settlement, no `recovery` or `operator_held` lifecycle, a passing `pairVirtualConservation`, no `pairEmergencyPendingFor` member, complete member valuation at the end and no incomplete funding event; an open position at the declared end is permitted when its valuation and accounting are complete.
Exit codes: 0 mechanically complete, 1 incomplete or process error, 2 usage, 3 refused, 75 injected fault; `scored` and `promotion_eligible` are always false for a pair result.

**Output.** One JSON document on standard output: status (`complete`, `incomplete`, `stopped`, `refused`, `error`), run info (fingerprints, lifetime, cursor, resume point, stop reason), member costs, `mechanically_complete` with sorted `incomplete_reasons`, every completed step record (host order trace, host state, partition risk summary, members with their own view marks and contract sizes, the owner readings `portfolioValueMarked`, `portfolioNotionalMarked`, `aggregatePerpsMarginInputsMarked`, `computeAssetDeltasMarked`, `evaluateExposureCapMarked`, `evaluateNotionalCapHoldMarked` and the member risk input status, pair lifecycle and obligations), failures, the `server.go` status fields read through `handleStatus` with live mark fetches disabled, the export and the comparison.
Step records keep every reading exactly as its owner returned it: the replay sums the per-member notional and margin readings in member order, `computeAssetDeltasMarked` walks sorted ids, and stored values are exported exactly.
The export reads the run file tables (strategies, positions, closed positions, trades, portfolio risk, kill switch events, basket contracts, member state, slots, actions, entry intents, quote uses, funding events and inventory, virtual allocations and execution events without `query` records).
The comparison hash is the SHA-256 of that export after the administrative projection: entry intent ids, trade position ids and trade row ids become first-appearance ordinals per kind in every foreign reference, and the progress `seq`, `progress_seq`, the step trace, the partition `total_pv` and `total_notional` of the step risk summary (the coordinator measurement sums them in map order; the per-member values, the owner readings and every risk outcome stay), the virtual ledger sequence and hashes, the failure rows and the storage audit columns are excluded; the raw hashes are verified before the projection.
`comparison.eligible` is true only for a mechanically complete run with no equality reason; an uninterrupted run and a run resumed after any fault with the same run id and inputs share the hash.

**Egress.** `basket-replay` keeps its local classification and joins the configless branch of `gateSubcommandEgress` (`egressConfiglessSubcommands`, shared with the offline `quote-replay` branch): in proxy mode it attests with no configuration and never reads `scheduler/config.json`, for version 1 as well; the version-2 replay builds no public source, account reader or exchange adapter.

**Limits.** Collection and valuation budgets are wall-clock checks of `collection_timeout_ms`, so a host far slower than that budget holds slots; fixtures use 2000 ms for in-memory reads.
A frozen book is used only through the recorded payloads; a policy bound rounded inside the adverse price of the worst consumed level leaves an IOC without a fill.
Pair research configurations refuse higher-timeframe filters, so no higher-timeframe series is cut.

**Tests.** `TestBasketReplayPairOffline*` (`pyintegration`, local only; CI excludes Python-backed basket tests) run the real binary, policy child and member checks: a complete enter, match, funding and exit run with owner proofs, every fault kind resumed to the uninterrupted hash, a sealed source resumed from a saved collection, an economic hold, process failures kept incomplete after resume, a scripted management failure that fires once across a restart, scripted failures that never fire reported as unapplied, missing quotes, an exhausted budget and a failed save, future-record independence, usage and refusal exits, a run with every exchange route blocked, the kill switch closing each member at its own mark, the plan-version-2 driver and its version-2 refusal records.
`TestBasketPairAdmissionExclusiveClaimHasOneWinner`, `TestBasketReplayConfiglessEgressBranchReadsNoDeployedConfig` and `TestBasketReplayPairStorageMigrationKeepsVersionOneRows` run untagged in a local basket test run (CI skips `^TestBasket`).

**Contracts for later children (#1882, #1883).**

| Name | Go signature or type | Rule |
|---|---|---|
| `pairReplayHooks` | `collected func(rt *basketRuntime, c pairCollectedSlot) error`, `frozen func(rt *basketRuntime, slotMs int64)`, `beforeSubmit func(a basketAction) error`, `submitted func(a basketAction, out basketVenueOutcome, err error)` on `basketCoordinator.pairReplay` | nil in production; `collected` runs after `buildPairEnvelope` and before `freezePairEnvelope` (an error holds the slot), `submitted` runs outside `mu` after `Submit` |
| `pairCollectedSlot` | `SlotMs`, `StartMs`, `DeadlineMs int64`, `Envelope pairEnvelope` | the checked collection a host saves before the freeze |
| `stepPairFundingDue` | `func (bc *basketCoordinator) stepPairFundingDue(in basketCycleInputs)` | host step 2; takes `mu`, skips refused or not cost-durable pairs |
| `pairContractBinder` | `bindContract(c basketContract) error` | an optional quote source method `pairQuoteSourceErrors` calls before the claim |
| `pairReplayHostState` | `Cycle`, `NextManageMs`, `LastStepMs`, `ViewGen`, `PrepSeq`, `Observations`, `EchoFailures` (snake_case) | the process memory a restart must restore before the first preparation |
| Phases | `pending`, `coordinator`, `complete` in `basket_replay_steps.phase` | the resume table above; internal saves write no phase |
| `basket_replay_failures` | `stage` (`policy`, `management`, `scenario`), `kind`, `step_index`, `slot_ms`, `member_id`, `cause` | policy and management rows keep a run incomplete; #1882 records outcome classes for paper |
| `mechanically_complete` | output field, exit 0 only when true | `scored` and `promotion_eligible` stay false for every pair result |
| `comparison` | `hash`, `eligible`, `reasons`, `raw_checks` | projection of `pairReplayAdministrativeFields`; equality claims need `eligible` |
| `okxPairManagementProbeArgv` | `[]string` | the OKX management vector both probe paths run |
| Driver plan v2 | `go-trader.basket-replay-plan` v2: `run_id`, `state_dir`, `source`, `research_config {path, sha256}`, `objects [{kind, member_id, path, sha256}]`, `scenario`, `bounds` | the driver hashes and sizes every file and exits 0 only for adapter exit 0 with `mechanically_complete` |

### Isolated paper recording (#1882)

`go-trader basket-replay-paper --spec <spec|-> --config <research egress file> [--max-steps N] [--fault <kind>:N] [--seal]` runs one research pair live on paper through the same version-2 host as Offline pair replay above (`scheduler/basket_replay_paper*.go`, `basket_replay_recorder.go`, `basket_replay_recording.go`, `pair_evidence_recorded.go`).
Every outside read and every process outcome commits to an append-only, hash-chained log before any owner uses it, so a crash resumes from committed inputs and a sealed run reproduces offline through `go-trader basket-replay` version 2.
It adds no second coordinator, policy caller, book writer or storage owner, never reads a deployed configuration, credential or account, and changes no default, promotion or live behaviour; `scored` and `promotion_eligible` stay false.

**Egress gate.** `basket-replay-paper` is a network subcommand with its own branch (`egressResearchSubcommands`, `gateResearchEgress`): `--config` must be an absolute clean path outside the deployed prefixes, a regular file (no symbolic link, real path equal, this user's, one hard link), different from the default configuration, read once through an `O_NOFOLLOW` descriptor whose identity equals the path before and after the read, one strict JSON object whose only key is `egress`.
The gate establishes the process egress from that value (proxy mode attests first, no direct fallback) and passes the decoded value to the command (`researchEgressAdmission`), so the path is never parsed or opened again; any refusal prints one refusal document and exits 3 before any read.

**Specification.** The version-2 replay fields with three changes: `input_manifest.objects` holds exactly one seed `evidence` object (capabilities, synthetic accounts and conversions; funding, quotes, books and candles are recorded live), a `collection` block `{from_ms, through_ms, drain_through_ms}` with `0 < from_ms < through_ms <= drain_through_ms`, and `scenario` adds `paper_poll_interval_ms` (1 ms to the pair interval bound), `paper_funding_poll_ms` (at least 1000), `paper_capability_refresh_ms` (0 = seed capabilities only) and the optional `funding_policy` (Pair funding, Research funding oracle above).
A new run with a policy writes paper manifest version 3 and the asynchronous event order (`pairPaperEventOrderAsync`); a run without one keeps version 1 and the legacy order. Resume refuses a changed version, policy or event order before any writable open. A saved version-2 manifest is not resumed live: `openPairPaper` returns `resume_refused`, naming family 2 and playback of a sealed family-2 export, before compare, restore, schema setup, probes or any run-file write. Family-2 playback keeps version 2 and `pairPaperEventOrderResearch`. The recording schema is per run: 1 for families 1 and 2, 2 for family 3. The header string stays `go-trader.paper-recording/1`; only the schema integer changes. A log that mixes schemas, or whose schema does not match the manifest family, is refused. No new table stores the oracle lifecycle; `call_consume` is a row in the existing append-only log and the recording cursor already moves when that row commits.
`bounds.start` must equal `from_ms` and `bounds.end` must equal `drain_through_ms`; `venue_failures` must be empty and `receipt_model` is `recorded_first_receipt`.
Every member must be `public_snapshot`: a `sealed_feed` or `archive` member is refused before the claim, since the recorder has no live sealed or archive producer and never relabels a public snapshot as one.
Before the claim the recorder checks `max_steps × 3 records × the encoded worst-case payload` against the 1 GiB object bound (`pairPaperMaxQuotesBytes`), so the quotes object of the longest admitted run still exports as one valid object.

**Collection and drain.** Collection reserves slots only for starts in `[from_ms, through_ms)` and makes no new decision at or after `through_ms`: a slot frozen before `through_ms` and still undecided then holds without a policy run (the terminal `decideHold` answer), so no slot ends the run undecided; the drain through `drain_through_ms` runs saved actions, funding, protection, recovery and emergency work only.
The recording hook `collectHold` refuses a slot before reservation, so a refusing period holds no slot.

**Recorder.** One call runner (`pairRecorder.call`) for every recorded kind: `collect` and its `member_read` children, `reference`, `match_poll`, `marks`, `management_candles`, `funding`, `funding_oracle` (#1934, never a process kind), `capability`, the bootstrap `account` and `conversions`, `policy` and `native_check` (`position` is reserved).
Live mode commits `call_start` (kind, phase, member, slot, action, step, ordinal, request and its hash), runs the call, commits `call_end` (class `result`, `infrastructure_failure` or `cancelled`, the result and its hash, raw receipt and completion times, redacted stderr, exit status) and only then returns; the logical clock moves to the larger of the previous logical time plus 1 ms and the raw completion.
A family-3 funding-oracle start is the exception: it commits `call_start` with `async` before the outside read, returns while the read is still pending, and does not move the logical clock.
Its `call_end` and `call_recovery` stay out of the consumable stream, so they can commit inside a step, at restart or at chunk stop without moving the cursor.
`call_consume` is the only new consumable item.
It names the call, the request hash, the terminal hash and kind, the wake, the step and the logical time, and it is the only event that publishes the observation and advances logical time for that call.
The call id is `<step_id>/<ordinal>` per host step (children `<parent>.<n>`, bootstrap `boot<epoch>/<n>`); bootstrap calls (probes, seed records) are never consumed.
Host wakes commit `host_wake` with the logical time `max(previous + 1 ms, wall)`; a wall clock that moves backward is marked and keeps the logical clock monotonic.
Wall budgets go through `bc.pairWallBudget(site, …)` at the `collect`, `reference`, `valuation` and `policy` sites, each started from `bc.pairBudgetStart()` (the recorder wall in paper): live mode commits a `budget` event with the elapsed time and cause, consume mode returns the recorded disposition.
Every paper wall read (wakes, call times, budgets, reader launch windows, the capability receipt, the wait for the next wake) goes through `pairPaperWall`, which is the real clock in every shipped build.
Funding settlement, settlement conversions and the reduction position refetch read only committed in-memory records in paper and playback, so their timeout contexts carry no recorded disposition.
The lock order is `mu`, then the recorder lock, then the one SQLite connection; nothing inside a save transaction, `saveExtra` or `storeCommitHook` takes the recorder lock, and the cycle snapshots the recorder position before its final save.

**Sources.** Match polls run at host boundaries on an anchored grid (`from_ms + k × paper_poll_interval_ms`) for each member in id order through the public snapshot readers (`fetch_pair_quotes.py`), so a poll's coverage is visible only from the cycle that committed it.
Collections and references are recorded live reads of the same readers; marks derive from the newest committed poll; management candles and funding come from `shared_scripts/fetch_pair_history.py --kind candles|funding` (adapter `public_candles`, `public_funding_window`, `public_mark_price_at`), which runs with credentials cleared behind the process egress and is in both probe vectors (`pairHistory*ProbeArgv`).
Funding uses a recorded funding source with source id `paper:<run_id>` and anchor `from_ms`; each poll seals a batch (`hyperliquid_window_records`, `okx_range_records`, `mark_price_record`, `seal_batch`) and Go appends it only after its call end commits.
OKX funding prices come from the 1-minute mark candle whose open equals the funding time; Hyperliquid has no public oracle price at a past funding time, so without a funding policy those events stay `price_missing` and keep the run incomplete.
With a funding policy (#1934) the Hyperliquid member's events price from one research oracle observation recorded near each UTC hour, under the research rule and its limits (Research funding oracle above); a missed, refused or conflicting sample keeps the held event `price_missing` or `conflicting_evidence`, and no mark or zero substitutes for it.

**Research oracle hour (#1934).** `oracleNextMs` holds the next hour boundary `T` (the first one at or after `from_ms`, none after `drain_through_ms`) and `pairReplayHostState.oracle_next_ms` checkpoints it; the attempt set and the raw high-water are rebuilt from the log, never saved twice.
The host wakes at `max(from_ms, T - 30000)`, at `T` and at `D`; `runStep` reads the committed `host_wake` row of the step (`wakeAt`, the recorder's own committed row in live mode and the verified log on resume and playback, never a database query), or in live mode the wake row the recording barrier queued (`queuedWake`), and `deliverOracle` runs first in delivery and decides only from that row, `oracleNextMs`, the frozen policy and the attempt index, never from a current clock.
A wake before preparation waits; a backward wake or a wake after `D` advances the hour with no call (`oracle_missed`); a committed start for the attempt id before the recorder cursor, or any committed start once the recorded tail is exhausted, advances it (`oracle_consumed`); otherwise the one recorded call runs, also from a wake exactly at `D`, and the hour advances whatever its outcome, except a call the recorder refused before any read (recording barrier or a start that did not commit, `pairRecCallNotMade`), which keeps the hour for the next wake (`oracle_not_made`).
A step whose wake the barrier queued decides from that queued row, so its oracle call is refused before any read and queues a `failure` row that the release commits and playback consumes at the same step; a resumed step with no committed wake makes no oracle call, because the queued rows of the stopped process never committed.
Family 2 playback still calls `readOracle`, which sends `{args, key, source_id, seq, prev_hash, target_event_ms, policy_digest, attempt_id}` and stores one sealed version 2 observation batch as the committed result.
A new family-3 run omits `seq` and `prev_hash` from the request.
The start commits first, then one worker runs the credential-cleared reader.
The worker does not touch trading state, storage, the recorder or the funding chain.
The host does not wait on it.
At a wake before `D` the host commits an already-ready terminal and its `call_consume` without waiting, then continues polls, decisions, management and rate funding.
At the first wake whose recorded wall is at or after `D`, the host joins the in-flight reader before match polls and lets it finish within the cleanup bound, cancels it only if it is still running, and commits the terminal and its consumption in that same delivery.
That wait is `pairOracleCleanupBound` (5 s of real time), not a wait until `D`.
A reader still running after it is cancelled and joined for one more cleanup bound.
`pairPaperAdmitFundingResult` still admits a family-2 `result` only when the batch checks pass and `End.RawCompletionMs <= D` (in tests each synchronous call adds one 20 ms tick).
A family-3 terminal holds the observation, not a batch.
The batch is sealed only inside the accepted `call_consume`, from the next sequence of batches whose calls the cursor has already consumed.
A refused observation allocates no sequence.
Any committed start for the attempt id blocks another start.
A committed start with no terminal is unknown and is never repeated.
A failed family-3 terminal append keeps the queued recovery as the only terminal; delivery waits for barrier clearance, consumes that recovery as interrupted, and advances the hour. Shutdown never retries the failed terminal.
`--max-steps` cancels a pending worker and joins it within the 5 s bound, commits the terminal and does not consume it, so that hour's sample is lost and the hour stays incomplete.
Drain end joins and consumes inside the deadline wake, so a deadline clipped to `drain_through_ms` can still be admitted.
`Close` and `fault` kill the worker process group before storage cleanup.
Production trading and a run with no funding policy are unchanged.
Evidence is a recorded evidence source (`pairRecordedEvidenceSource`): the seed document plus each committed capability refresh, visible from its commit logical time; its identity digest hashes `{v, source_kind recorded, source_id paper:<run_id>, anchor from_ms, seed digest}`; admission keeps it through `pairAdmittedEvidenceFor` and every consumer reads `admittedEvidence()`.
Native checks run through scoped hooks: the Hyperliquid frame passes a recorded runner as `marketFeedContext.StdinRun` and OKX uses `okxPairManagementCheckWith`; the live defaults are unchanged.
A native-check result hash covers its stdout without the top-level `timestamp` field; stderr is stored redacted and is never hashed. `run_pair_policy.py` reads no clock, so the policy hash is its full stdout.

**Host cycle.** Wake, pending row (with the recording position), recorded delivery (with a funding policy first the due oracle hour — family 3 settles an already-ready result or joins at `D` before polls, and otherwise leaves the read pending — then match polls, capability refreshes, funding reads due at the wake), coordinator funding, `preparePairRisk`, partition risk and the paper kill switch, member management on a management cycle (one recorded candle read, own mark and member check per member), barrier sync, coordinator checkpoint, a second `preparePairRisk` at the current logical time, `bc.step`, end of step, final save with the step record and the recording cursor. The cursor moves when `call_consume` commits, so the coordinator checkpoint and the final save both capture the consumption position.
The second preparation keeps the valuation input of `bc.step` equal across a coordinator-phase restart, because the recorded delivery moves the logical clock inside the cycle.
In paper and playback the host state (`pairReplayHostState`) also carries the published valuation view and the last valid mark per member (`pair_view`, `pair_last_marks`), so a restart at any host point keeps the same substitution and mark-lost readings as an uninterrupted run; offline version 2 without paper leaves both fields empty.

**Recording barrier.** A failed append sets `pairProgress.RecordingBlocked {reason, failed_seq, at_ms}` (progress version 4, written only when the field is present; the flat reset carries it).
While it is set the coordinator holds collection (`collectHold`), new decisions (`decideHold`), position increases (`pairIncreaseHold`) and an increase at the submit skip lane (it finishes as no fill through `finishPairNoFill`).
Reductions, protection, recovery and emergency closes still run, but only on inputs committed before the failure: the recorder refuses every new outside read (no match poll, mark, management candle, member check or capability refresh), so a staged close fills only against polls committed before the failure and member checks stop until the barrier clears.
Queued events (`record_failure`, `host_wake`, `budget`) do not move the recording cursor until they commit with the release, so every saved cursor counts committed stream rows only.
Each refused call commits a `record_failure` event when the log accepts it again, so playback reproduces the refusal; the barrier clears only after `verifyRecordingForClear` reads the full committed chain and it equals the recorder head (a `barrier_clear` event), or at restart after the full chain verifies, which also removes the equality claim.
The restart release lands after any committed tail; the recorder rereads the committed log after it, a resumed step consumes the tail and passes over the release (playback consumes it where it meets it), and a run with no tail counts it at once.

**Resume.** Resume checks the directory (`pairPaperResumeEntries` adds `export` and `seal.json`), the paper manifest and, before admission, a read-only snapshot of the run file: it verifies the chain and appends the committed funding batches in order, then admits, rechecks the same frontier after the store opens and restores every committed source record (seed and capability evidence at their commit logical times, match polls, funding) before the saved pair state loads, so an applied funding event finds its conversion.
It commits an `epoch` event, a `call_recovery` event for every call without a terminal, reruns the probes, then consumes the committed stream tail from the durable recording cursor before it records live again; a sealed run refuses resume.

| Kind | Committed terminal at restart | Start without terminal |
|---|---|---|
| `collect`, `member_read` | saved result consumed; a reserved slot whose collection committed is collected again from the record, and `recoverInterrupted` skips it; before a slot frozen ahead of the restart is decided, its recorded collect is consumed (`catchUpCollect`), so the decision runs at the recorded logical time | unknown; the slot holds |
| `reference`, `marks`, `match_poll`, `management_candles`, `capability` | saved result consumed in order | unknown; the owner treats it as a failed read |
| `funding` | the batch is already appended before admission; the resumed request is built from the batches whose calls the cursor already consumed (`fundingConsumed`), so it equals the recorded request and the saved batch is consumed, never appended twice | unknown; the next poll reads again from the last committed batch |
| `funding_oracle` | family 1 and 2: admitted before admission by `pairPaperAdmitFundingResult` in log order (a refused terminal is skipped; only an unreadable batch refuses resume) and consumed from the tail, so a restart after `D` still uses the committed sample. Family 3: only an accepted `call_consume` whose stream index is inside the durable frontier is admitted; the terminal itself is not. A rate batch committed between the start and the consumption keeps its start-position index, so the resumed rate request still matches | unknown, no price, never repeated; a committed start without a terminal stays unknown; two committed starts for one attempt id refuse resume |
| `policy`, `native_check` | saved result consumed, no process launch | unknown; recorded as a process failure, never launched again |
| `budget`, `host_wake` | saved disposition or logical time consumed | not a call |

**Outcome classes.** `policy` and `native_check` with class `infrastructure_failure` or `cancelled`, an unreadable policy output, an interrupted call and a `record_failure` keep the run mechanically incomplete; failure rows link their call through `basket_replay_failures.call_id`.
Every other kind ending `infrastructure_failure` or `cancelled` is a hold handled by its owner (the slot holds, the reference or valuation is missing for that cycle, the funding poll retries); completion still follows the version-2 rules.

**Seal and export.** `--seal` on a finished run with no pending step, no unterminated call, no barrier and no divergence writes `<state_dir>/export/` (`evidence.json`, `funding.json`, `quotes-<member>.json`, `candles-<member>.json`, `seed-evidence.json`, `recording-log.jsonl`, `recording-manifest.json`, `reproduce-spec.json`), each file through a partial file and a hard link so an export file is never replaced, then commits the `seal` event, then writes `seal.json` (`O_EXCL`).
`reproduce-spec.json` is a version-2 replay specification with the paper run id, the export directory as input root, the exported objects and a top-level `recording {version, path, sha256, size_bytes}` naming the manifest.
With a funding policy a new run's `funding.json` is a `pairFundingDocumentV2` with the policy and every admitted observation, `recording-manifest.json` is version 3 and the reference version is 3. A sealed family-2 export keeps version 2. Without a policy all three keep version 1. `standalone_notes` says that an oracle observation keeps `at_ms = visible_ms = receipt_ms`, so a standalone replay without the recording sees the sample at receipt, while the live run publishes it only at the later consumption step. Playback requires matching reference, manifest, policy and binding families, rebuilds the recorded source with `newPairRecordedFundingSourceWithPolicy` before admission, consumes saved oracle calls and never runs the reader. Family-3 playback publishes a batch only at `call_consume`.
On resume, family-3 playback restores funding only through its own durable cursor or pending coordinator checkpoint. Source restoration adds no later rate batch. The verified log indexes each row's stream position once for funding restore and attempt lookup.

**Playback.** With `recording`, version 2 verifies the manifest, the log chain, its final sequence and the binding, requires the recorded run id, research configuration, scenario, lifetime and object set, rebuilds the recorded evidence and funding sources from the log, and takes every cycle time from the recorded wakes.
Each non-process call returns its recorded outcome; `policy` and `native_check` results rerun from their saved request and must reproduce the saved result hash; recorded failures and interruptions are reproduced without a launch.
A missing, extra or changed call diverges and removes the equality claim; an uninterrupted paper run and its playback share the comparison hash.

**Faults.** The version-2 kinds plus `exit-after-call-start:N` (after the Nth call start commits), `exit-after-input-commit:N` (after the Nth log append) and `fail-record-append:N` (every append from the Nth fails until a verification clears it); each prints `fault <kind>:<N>` and exits 75. `--fault` may be given twice with one version-2 kind and one recorder kind.

**Driver.** `backtest/basket_replay.py --paper --config <file> [--seal] [--max-steps N]` runs a version-2 plan with a `collection` block through `basket-replay-paper`; a plan with a `recording` reference runs the offline playback; a paper run has no wall timeout unless `--timeout` is given; every chunk writes its own new `--out`.

**Tests.** `TestBasketReplayPairPaper*` (`pyintegration`, local only; CI excludes Python-backed basket tests) run the real binary against patched public venues: record before use and blocked-network reproduction, a forged process result, every fault boundary resumed and reproduced, configuration and specification refusals, an append failure held through a restart, an altered recording refused before trading, the driver chunks, an applied OKX funding event resumed in chunks and after a crash, a crash inside a step with funding reads resumed without a repeated call, and the recording cursor across a blocked chunk and a blocked pending step followed by later crashes.
The research oracle cases (#1934) are `TestBasketReplayPairPaperResearchPolicyAdmission`, `...ResearchOracleTiming`, `...ResearchOracleReproducesOffline`, `...ResearchOracleStandaloneFunding`, `...ResearchOracleResume` and the `research oracle` subtests of the fault family; the fixture serves `metaAndAssetCtxs` with `oracle_px`, `oracle_context_mode`, `oracle_setup_delay_ms`, `oracle_response_delay_ms` and `oracle_clock_jump_ms`, and patches `time.sleep` to advance the test clock only inside a `fetch_pair_history.py --kind=funding_oracle` process.
They build the binary with the `pyintegration` tag, whose `basket_replay_paper_testclock.go` replaces `pairPaperWall` with a virtual clock in the file named by `GO_TRADER_PAPER_TEST_CLOCK` (each recorded call adds 20 ms, a wait jumps to its target). When `GO_TRADER_PAPER_CLOCK_GATE` is set, Go is the only writer of that file: the oracle child registers each sleep target and blocks until Go advances the clock and the child deletes the request. A wait has a 60 s real-time watchdog. Without the gate, a standalone `fetch_pair_history.py --kind=funding_oracle` process still advances the clock itself, so `TestBasketPairFundingPythonPublicOracleReader` is unchanged. A race-enabled test builds that child with `-race` and fails on exit 66 or `WARNING: DATA RACE`.
`TestBasketReplayPairPaperResearchReceiptBeforeExitMatchesPlayback` uses `oracle_output_delay_ms` and `GO_TRADER_PAPER_TEST_ORACLE_COMPLETION_MS` to separate receipt from reader exit. In that opt-in mode the test wall ticks by 1 ms and holds the completion time until the worker captures it. The real-binary test stops playback after the intervening poll wake, checks an empty oracle collection and an equal step hash, then resumes and checks the full comparison hash. `TestBasketPairRecorder*` and `TestBasketPairRecordingBarrierHoldsIncreasesWhileEmergencyClosesContinue` run untagged.

**Contracts for later children (#1883).**

| Name | Form | Rule |
|---|---|---|
| Export layout | `<state_dir>/export/` with the eight files above | written once at seal; `recording-manifest.json` (`go-trader.paper-recording` v1) lists every object with sha256 and size, the log reference with its final sequence and prefix hash, the evidence identity, the funding binding, the member costs and `standalone_notes` |
| Standalone objects | `evidence`, `funding`, `quotes`, `candles` | each object is a valid version-2 input on its own (without `recording`); a gap or an empty member is listed in `standalone_notes` |
| `recording` | `{version 1, 2 or 3, path, sha256, size_bytes}` in the version-2 spec and `recording {path, sha256}` in the driver plan v2 | present = playback from the sealed log; absent = the version-2 rules and fingerprint unchanged; version 2 (#1934) names a sealed family-2 research manifest; version 3 (#1936) names a family-3 asynchronous oracle manifest; the driver reads the version from the manifest |
| Funding policy (#1934, #1936) | `scenario.funding_policy` (`pairFundingPolicyV1`) in the paper spec, the paper and recording manifests and the v2 funding binding and document | absent = legacy rules and version 1 objects; a new run with a policy writes version 3 manifests and schema 2 events; a saved version 2 manifest plays back and is not resumed live |
| Outcome classes | `result`, `infrastructure_failure`, `cancelled` on `call_end` | the mapping under Outcome classes above |
| Run output | `recording` (mode, head, calls, interrupted calls, record failures, epochs, barrier, stream position, `seal`) | the driver copies it into the paper result record |
| Call identity | `<step_id>/<ordinal>`, children `<parent>.<n>` | stable across resume and playback |

## Quote Lifecycle (#1860, research-only)

A durable, partition-owned quote lifecycle for Hyperliquid perps with one settlement authority.
It is **default-off and production-refused**: no deployed configuration can run it.
Live activation needs a separate reviewed promotion and an operator decision.
Part 3 (#1861) owns the quote policy, the paper venue, the `quote-replay` runner and the study; this section owns the coordinator, storage, settlement, safety and controls those parts run through.

**Configuration and refusal.** A quote owner is a Hyperliquid perps strategy with a `quote` block (`coin`, `max_inventory_usd`, `max_order_qty`, `request_expiry_ms`, optional `fee_reserve_rate`, `margin_price_buffer_pct`), `close_strategy: inventory_quote_exit` and exactly one static percent stop (`stop_loss_pct` or `stop_loss_margin_pct`, inside the isolated bankruptcy bound in paper as well as live; cross margin keeps its exception).
A non-empty `symbol` must be exactly the quote coin, and the position, funding anchor and stop all use the quote coin as their key.
ATR, regime and trailing stop owners, the `max_drawdown_pct` fallback, tiered take-profit, ratchet, hedge, scale-in, baskets and replay sharing are refused.
Unknown keys, booleans as numbers and non-finite values fail the raw parse.
Both production hooks (`loadConfigData` and `validateConfig`) run `quoteAdmissionErrors`: with no admission every quote block, every `quoteCapabilityStrategies` seam name and every `inventory_quote_exit` reference is refused in every mode and with any `allow_no_edge` form.
`openStrategyRegisteredFor`, `hyperliquidBatchEligible`, `runHyperliquidCheck` (scalar, composer and batch fallback), `runHyperliquidPerpsStep`, `runHyperliquidExecuteOrder`, `force-close`, add-strategy and paper-to-live refuse a quote owner even in a directly built configuration.
`inventory_quote_exit` carries the close registry's `research_only` marker: ordinary discovery (`--list-json`), evaluation and close fallback refuse it, and the parity test pins the Go `researchOnlyCloseStrategies` set to it.

**Internal admission.** Only code creates an admission; no key, flag, environment variable or route can.
The load form `quoteLoadAdmission` (kind, owner id, run state directory, SHA-256 of the exact configuration bytes; no store) comes from `newQuoteResearchLoadAdmission` (the #1861 runner) or from a `_test.go` fixture constructor.
`loadQuoteAdmittedConfigData(data, adm)` is the only loader that sets it (read-only, credential-skip, no file write; a digest mismatch refuses).
The research kind needs a paper partition, one explicit `--mode=paper` and a seam open name whose parameters pass the seam validator; only that owner's open-role reference is exempt from `productionRefusedAdmissionErrors`.
`bindQuoteAdmission(load, cfg, store, ownership, venue, clock)` binds once: the store must come from this configuration's layout and identity map, every file must be a real file inside the admitted directory with ownership held, and the venue must match the partition (research: provider `paper`, account `paper:<partition>`, not live-shaped).
The policy fingerprint is a digest of the open name, seam specification version, canonical open params and quote block; the configuration fingerprint is the load digest.
Both are saved with the contract and a resumed process with a different one refuses.

**Deadline call.** `RunDeadline(ctx, adm, req)` takes the deadline, deadline marks (`EvidenceMs`, `MaxAgeMs`), paper funding coverage (`paperFundingInputs`), the input-health verdict and a pure decision callback, and reads only the injected clock.
Stages: refuse an earlier deadline (equal repeats; inside the effective cadence is not due); create the contract at the first call (pre-existing exposure or unrelated pending orders refuse); read the venue and stage the settlement outside `mu`; call the callback on the post-settlement staged snapshot (or the durable one with the settlement holds); in one write hold of `mu` recheck the revision, commit one owning-file transaction (the settlement plus the caller's `WriteQuoteProgress` record, or a progress-only transaction) and swap; then run holds, the stop owner, the risk envelope, reservations, submissions and cancellations.
At most one settlement per call; fills confirmed while applying settle later.
A revision mismatch, failed commit or progress error reloads durably and returns a `quoteReloadError` (the deadline is a resume point).
`RecoverOnStart` resolves uncertain orders and settles before any new submission.

**Settlement (sole sink).** Read the fill history from the applied boundary (with overlap), read the strict position, recheck the history through that read (bounded retries; fills that keep arriving hold).
Every fill must validate, name this account and namespace and attribute to a saved quote, stop or close identity; an unrelated fill, a conflicting copy, incomplete or retention-limited history, a non-advancing page, a contradiction with the settled prefix (an unapplied fill at or before the applied boundary), an ambiguous tied chain (equal times need one unique chain from their starting positions) or a final exposure mismatch hold the whole uncommitted prefix, also at flat.
Arithmetic is exact (`big.Rat`): same-side fills add at weighted cost (`scale_in` trade type), opposite fills realize gross PnL from the saved basis (venue `closedPnl` never enters `realized_pnl`), a reversal closes and opens a residual with a new position identity and an exactly split fee, partial reductions keep the initial quantity, and floats are projections checked against the exact totals.
The applied coverage boundary is the position read time minus one millisecond.
A fee correction adjusts cash, fees and daily PnL once with no quantity replay; an unsupported fee asset holds.
Each correction of a fill starts from the fee the previous correction set (applied earlier or in the same settlement), so the booked fee equals the newest correction; a correction with a lower row id than one already applied holds.

**Paper funding.** The settlement is the only funding writer for a quote owner: fills move inventory through the event-time `mutatePaperPerpsBookAt` (an earlier event than the anchor is a contradiction, never clamped), only the settlement advances the anchor's verified time, and the shared per-strategy funding core (`paperFundingSampleCoins` + `paperFundingSettleCoins`) books records with settled time < record time <= min(coverage end, applied boundary).
The generic loop (`planPaperFunding`, `runPaperFundingAccounting`, `syncPaperFundingEligibility`) skips quote owners.
Live funding for a quote-owned coin is saved in `quote_funding_evidence` and applied once by the settlement.

**Risk envelope and holds.** Before either side is sent the whole pair, its envelope inputs and both identities commit in one transaction; the request is persisted `submitting` before the send and the cancel intent before a cancel.
The signed inventory interval runs from settled inventory plus confirmed-unapplied fills, minus every remaining sell through plus every remaining buy (existing, unknown, cancelling and proposed; reduce-only, stop and close orders excluded).
Each extreme must pass the marked inventory cap, the partition notional cap, the direction-aware exposure cap (`evaluateExposureCap`) and verified free margin with a conservative price and a non-negative fee reserve; the envelope is rechecked before each risk-increasing send.
Pause, circuit breaker, daily loss, kill switch, persistence hold, operator hold, a missing, unhealthy, stale, incompatible or out-of-order verdict, a stale mark, a failed evaluation, a held settlement, an unknown order outcome and nonzero inventory without a confirmed resting stop hold increases and cancel risk-increasing resting quotes; reductions (reduce-only reducing quotes, the exit lane) and the stop continue.
A replacement waits until both predecessor sides are terminal and settled; an identical desired side keeps its identity.

**Stop and exit.** The coordinator is the stop owner: side opposite the settled inventory, size through `hlOwnStopShare` against the fresh read, trigger from `RiskAnchorPrice` via `hlLiquidationScalarRearmTriggerPx` (one-way tightening clamp), rounded toward the market.
A changed stop is verified on the venue before any cancel; an unknown outcome keeps both identities unresolved with one CRITICAL alert.
`inventory_quote_exit` handles caller age/cap exits, a price-driven cap breach and a latched kill switch: freeze increases, cancel quotes, wait for settlement, then send a reduce-only IOC close sized by `planHLCloseOrder`; a partial exit re-sizes the stop through `resolveHLCloseRemainderStop`.
Every stop, close and kill-switch fill settles only through the sink; ordinary protection, paper stop owners, circuit-breaker fills, kill-switch settled legs, generic reconciliation, backfills and trade stamping skip or refuse a quote owner.

**Venue and adapter.** `quoteVenue` (provider, account key, namespace, live-shaped flag, client ids, place/cancel/status by saved identity, raw fill recovery with coverage, strict position read) has a live implementation over `check_hyperliquid.py --quote-place | --quote-cancel | --quote-status | --quote-recover | --quote-position`.
Place and cancel run through `runPythonSideEffectWithTimeout`, never `runPython`; reads take the caller's bounded context.
The adapter's `limit_open` takes `cloid`, `reduce_only` and `require_exact` (a request that rounding would change is refused before submission), `set_order_expiry` bounds acceptance (`now < expiry`), the stop role uses `place_stop_loss` with a saved cloid, and `strict_position` returns the exact signed size, liquidation price and withdrawable margin.
`--quote-status` reports the filled quantity as exact decimal text (`origSz - sz`), and the settlement saves the confirmed quantity as the larger of the confirmed and applied quantities, so a terminal order resolves once its fills are applied.
The quote scanner compares repeated identities before deduplication and reports conflicts.
`quotePriceRule` (`roundQuotePx`: buy down, sell up; `roundTriggerPx`: toward the market; `floorQuoteSize`) refuses any request rounding would change.
All six quote modes are in both startup probe paths (`quoteProbeArgvs`) and exit on `--probe-only` before adapter construction.

**Boundary rules.** Caps pass at equality; margin refuses only when available < required; a mark is fresh when `EvidenceMs <= deadline` and `deadline - EvidenceMs <= MaxAgeMs`; submission and acceptance happen only when `now < expiry`; an uncertain absence proves "never accepted" only when `now > expiry + 2m` and the applied boundary reaches `expiry + 2m`; the liquidation clamp applies at equality; an earlier deadline refuses and an equal one repeats.

**Storage, startup and shutdown.** All quote access goes through `StateStore`; legacy pending-limit paths stay manual-only, and `LoadPendingOrdersCombined` returns manual rows plus every file's quote rows with source role, partition and strategy (one failed file fails the read).
`reconcilePendingLimitOrders` dispatches quote rows before the manual gates: to the bound owner, or held untouched when no admitted coordinator owns them.
The kill-switch cancel-before-flatten lane adds an owner-qualified quote lane; unresolved quote evidence prevents a confirmed-flat result.
Production startup runs the quote recovery hook (a no-op without an admission) before reconciliation, and storage inspection refuses (exit 80) any quote contract, flat or not, and any unresolved quote evidence that no admitted owner runs, because every full save refuses an unowned contract.
An order write never erases a saved cancel request (a terminal outcome closes it as confirmed) and never lowers the saved confirmed quantity.
Shutdown persists cancel requests and cancels risk-increasing quotes with its own bounded context before `runDrain`, keeping a confirmed stop on nonzero inventory; unknown outcomes survive for restart.
SIGHUP refuses ownership, policy, cadence, close/stop and storage changes while quotes, unresolved intents or inventory exist.

**Diagnostics and controls.** `GET /api/quotes?partition=` shows each owned snapshot (deadline, revision, fingerprints, exact and projected inventory, first opening fill time, generation and its sides with identities, acceptance time and confirmed/applied quantities, envelope, holds, stop, applied boundary, funding coverage start and held records) plus held durable evidence.
`POST /api/quotes/cancel` and `/api/quotes/hold` need an explicit owned partition (400 malformed, 404 unowned), same origin and JSON; a cancel targets only the current generation's quote sides (never the stop) and is idempotent.
Controls cannot enable production, waive coverage, ownership or position proof, or adopt inventory.

### Offline quote replay (#1861)

`go-trader quote-replay --spec <plan|-> [--max-deadlines N] [--fault <point>:<n>]` runs the production-refused `inventory_market_making` policy through the real coordinator, settlement, envelope, stop and exit lanes above; `quote-replay discover` prints the policy specification and its hash. `backtest/quote_replay.py` is the driver (`discover`, `plan`, `run`, `verify`) and `backtest/candidates/inventory_market_making_1636/` holds the frozen study.
It is research-only: it adds no second booking path, writer or admission form, and no key, flag or environment variable can admit the candidate elsewhere.

**Plan and isolation.** Input is one hashed `go-trader.quote-replay` v1 plan (unknown fields refused): mode, run (`run_id`, an absolute real `state_dir`, source provenance), coin, deadline window (whole seconds, at most `max_deadlines`), every policy parameter, cycle, cap and stop, the quote block, lot decimals, costs, latencies, the queue model, pinned funding records, books (receipt and event times, levels), book coverage-break points, deduplicated raw trades and raw-trade coverage ranges.
Before any state opens it refuses venue credential names (`HYPERLIQUID_`, `OKX_`, `ROBINHOOD_`, `TOPSTEP_`, `DERIBIT_`, `IBKR_`, `BINANCE_`, `LUNO_`), scrubs notifier and token variables, refuses deployed paths, a parent behind a symbolic link, new mode on an existing path and resume of any directory it did not create (manifest, single marker row, owner, entries, hard links and every fingerprint must match); a moved directory refuses at binding through the saved configuration fingerprint.
It builds its own paper configuration (one owner `quote-replay-owner`, `inventory_market_making`, `--mode=paper`, `inventory_quote_exit`, `stop_loss_pct`, isolated margin, leverage 1, capital USD 10000, circuit breaker off), loads it only through `newQuoteResearchLoadAdmission` and `loadQuoteAdmittedConfigData`, checks `quoteReplayConfigRefusal` (one owner, exactly the owner's frozen book need, no other observation, the spec's cross-field rules), takes ownership and binds a paper venue (`provider paper`, account `paper:<partition>`).

**Deadline order.** At each due deadline the clock moves to the deadline, the venue processes recorded events through it and persists fills, the runner builds the evidence (last eligible book within `max_input_age_ms` by receipt and event, one sample per closed second over the volatility window, no coverage break, raw-trade coverage over the cycle), the verdict, the deadline mark (book midpoint, receipt evidence time) and pinned funding coverage, then calls `RunDeadline`.
The decision callback calls the pure policy (`scheduler/quote_policy.go`) on the staged snapshot and returns the immutable intent record, the venue checkpoint and the progress record; the result row is completed after the call.
A receipt after a deadline never enters its evidence, seal fingerprint or intent.

**Paper venue.** Every place and cancel takes effect at max(call clock, previous effect) + its latency and moves the simulated clock there; an effect at or after the next deadline refuses the run.
Quotes are post-only against the recorded book at the effect time (a crossing or out-of-depth price is rejected); a resting quote fills only on a later opposing aggressor trade strictly through its price, at its price, capped at the frozen participation of each trade across all own orders; trades at its price only reduce the queue ahead, depth increases are charged ahead and depth decreases never improve rank.
Stops trigger on the first recorded trade after acceptance at or through the trigger and fill as takers against recorded depth less the slippage reserve; the inventory close fills inside its limit; reduce-only orders never cross flat and are cancelled when their reducing position is gone.
Reads at a time see market events through it and every persisted own answer; fill times, coverage and the position sum use the receipt basis.
A raw-trade coverage break while an own order rests makes fill history incomplete, the settlement holds and the window ends unscored.

**Restart classes.** A completed result, a rollback before the settlement and progress commit, and a venue answer persisted before the deadline progress resume to records equal to an uninterrupted run (position ids keep a process-local nonce that comparisons drop).
A crash after the progress commit leaves the result pending: resume marks it interrupted, repeats the deadline with the saved intent and no policy call (any settlement kind commits with a recovery row), never resends a saved identity and stays unscored; the recovered ledger still equals the independent ledger of its durable events.
A callback fault that settles without progress leaves the contract ahead of the cursor and refuses resume.
`--fault` points: `issue-before-reserve`, `venue-before-persist`, `venue-after-persist`, `progress-before-write`, `progress-mid-write`, `progress-after-write`, `commit-hook`, `after-call`, `after-result`, `decide-error`; exit 75.

**Verification.** `quote_replay.py verify` checks every fill against the plan (source trade, receipt after acceptance, strict trade-through, participation budget, lot grid, fee, taker level less slippage, start-position chain, reduce-only never crossing flat), rebuilds the exact ledger and the paper funding samples and payments independently and compares them with the Go book, funding rows and position identities.
Depth-only and archive input (receipt basis `archiver_receipt_ns_floor_ms/1635-v1`, cache objects verified by `archive_cache.verified_object`, never downloaded) supplies no queue evidence and is never scored.
Output JSON lists decisions, results, venue events, fills, settlements, trades, the exact book, pending orders and the measured `RunDeadline` time per deadline (controlled, never production latency); exit codes 0 run, 1 error, 2 usage, 3 refused, 75 fault.

## Closed-Bar Decisions

`closed_bar_decisions: true` (#1712) makes a strategy decide on the last closed bar, the bar the backtester trades from (signal at bar N, fill at the open of bar N+1, entry ATR of bar N).
It is off by default; the default stays off until a paper comparison measures the effect.

**Supported scope.** Binance.US spot (`shared_scripts/check_strategy.py`), OKX spot and perps (`shared_scripts/check_okx.py`), Hyperliquid perps (`shared_scripts/check_hyperliquid.py`) and Robinhood paper spot for Yahoo crypto pairs only (`shared_scripts/check_robinhood.py`, #1822; § Robinhood candles), legacy and composed strategies, on the fixed-duration timeframes `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `6h`, `8h`, `12h` and `1d` (Hyperliquid: only its own intervals; Robinhood: only those in its timeframe table and not `1m`, so not `1m` or `3m`).
`loadConfig` refuses the flag on `manual`, options, TopStep, live Robinhood (live fills settle through `robinhood_order_attempts` and the #1769 entry ledger has no Robinhood provider; `check_robinhood.py --mode=live --closed-bar-decisions` refuses with the same reason), a Robinhood symbol that is not a Yahoo crypto pair (its session-shaped Yahoo rows have no rows outside session hours and hourly and longer rows off the UTC grid, so the freshness rule would hold every decision; `check_robinhood.py` refuses with the same reason), a custom check script, another timeframe, a higher-timeframe filter or regime timeframe outside that list, `delta_neutral_funding`, `open_interest_breakout`, `funding_skew` on OKX, and `regime_directional_policy`, `regime_window_divergence` or `regime_profile_allocation` (those resolve before the check runs, so they would read the current regime).
The reverse rule (`closedBarRequiredOpenStrategies`): `loadConfig` refuses every `schaff_trend_cycle`, `camarilla_pivot_reversal`, `stochastic_momentum_index`, `calendar_conditioned_entry` or `klinger_divergence` reference (positional open, `open_strategy`, `close_strategy` fallback) unless the strategy is Hyperliquid perps with the flag on, because its last-bar decision needs the verified closed-bar cutoff (#1651, #1669, #1663, #1653, #1662).
A replay mirror with the flag needs an explicit `replay_source_id`, and a named mirror and its source must agree on the flag.
Changing the flag is restart-required; SIGHUP refuses it.

**Closure.** The check captures one evaluation cutoff: the sealed snapshot's deadline (or its seal time when earlier or absent) in `market_feed: websocket|shared`, or the clock before the fetch in REST mode.
A bar is closed when its close boundary is at or before the cutoff; a final row that is already closed is kept.
Hyperliquid rows keep their native `t` and `T` (`T = t + interval - 1`), and the sealed payload carries a per-row `timing` sidecar plus `decision_cutoff_ms` only for frames an enabled strategy requests; Binance.US and OKX use the ccxt opening time plus the fixed interval.
Robinhood uses the Yahoo opening time plus the fixed interval (resampled buckets use their target interval) and adds a freshness rule to the primary, regime and HTF selections: the selected bar must be the newest bar that closed at or before its cutoff, Yahoo must already show the next (forming) row, and the cutoff must be at least `YAHOO_SETTLE_MS` (20 s, `check_robinhood.py`) after that bar's close; otherwise the decision holds, so a missing or unsettled Yahoo row never moves the decision to an older bar.
The rule comes from a live Yahoo measurement for #1822 (BTC-USD and ETH-USD): rows are UTC, 24/7 and on the epoch grid of their interval, with the forming interval as the final row and no off-grid live row; the newest closed `5m` row changed in 2 of 8 sampled boundaries (its low moved) between 10 and 15 s after the close, at the moment the next row appeared, and never after it; the `1h` row did not change from 3 s to 900 s after one sampled boundary; and the `1h` series has gaps (11, 20 and 1 missing hours over one year), which drop the resampled buckets that hold them.
A Robinhood `1m` closed-bar decision stays refused in Go and Python: the newest closed `1m` row was a flat zero-volume placeholder in 6 of 8 sampled minutes until the next minute's row appeared, 10 to 20 s after the close, so the settle hold would cover a third of every interval.
A check that runs within the settle window after a boundary holds and the next check retries the same bar (Repeat rule); give a Robinhood closed-bar strategy an `interval_seconds` below its bar interval so a check phase that lands in the window does not hold every bar.
The row timestamps a strategy sees are unchanged.
Unknown or contradictory timing, duplicate or overlapping bars, invalid prices, fewer than 30 closed bars, a payload without timing (an older producer), a missing dependency or a failed regime or higher-timeframe candle fetch hold the candle decision: the check returns `closed_bar_decision.held: true`, no open and no candle-derived close, and composed close evaluators still run on current inputs.
A sealed-feed check never falls back to a private fetch.
Limitation under `market_feed: websocket` and `shared`: the seal settles 5s after the deadline, while the first closed-bar correction read starts at close+5s, so the first check after a boundary usually decides on the socket-stored bar before any correction (§ Closed-bar correction); a venue revision that lands later reaches only later seals, so a booked open, its sizing and its entry ATR can differ from the backtester's corrected bar.
A later check on the same bar re-evaluates the corrected values (Repeat rule). `market_feed: rest` fetches after the boundary and has no such gap.
Enabled strategies request one more raw row (signal, higher-timeframe and regime frames) so the closed view keeps the same history length.

**Decision and protection views.** On the closed view: the open strategy, candle-derived closes (legacy signals and unknown-close fallbacks), `indicators.atr` (the strategy's own ATR column, else the `atr_method` ATR), the higher-timeframe filter (bars closed by the decision boundary), `funding_skew` records (time at or before the boundary), and the decision regime, computed in the check from the regime timeframe's closed bars and returned as `decision_regime`.
The regime gate and the Hurst gate read `decision_regime`; a missing one holds position-increasing signals even when `regime_gate_on_failure` is `open`.
On the current view: the price and mark override, the market ATR, the injected regime and AVWAP passed to close evaluators, the regime store, position regime stamps, divergence, dynamic exits and every Go protection path.
Entry sizing and `Position.EntryATR` both read the closed `indicators.atr`; an existing position keeps a nonzero entry ATR, and a zero entry ATR is stamped from the next check whose decision is not held, so ATR stops still arm.
An enabled replay mirror sizes and stamps from the source row's `entry_atr`; an open row without a valid one is not booked: the mirror sends an `open-without-entry-atr` drift alert, marks the row applied and applies the later rows.

**Repeat rule.** Every due check re-evaluates the selected closed bar against the current position and gates, so a gate hold, a failed check or a failed execution retries the same bar on the next check, and existing same-side guards, scale-in rules and fill confirmation stay authoritative.

**Entry allowance (#1769).** One closed decision candle permits at most one confirmed primary increase per strategy and target side: a new position or an enabled scale-in add.
A check interval shorter than the candle (the global default 600 s, or the 90 s drawdown-warning cadence) therefore cannot open the same side again from the same candle after a stop closes the first entry.
The scheduler checks the allowance after every other entry gate, at the closed-bar dispatch sites (Hyperliquid after the solo/batch join, the shared OKX step `runOKXStep` for OKX spot and perps, Binance.US spot paper).
A held entry only zeroes the position-increasing signal, so closes, stops, trailing stops, ratchets, protection and hedge unwind continue.
The allowance holds an entry when the decision identity is missing, held, malformed (interval mismatch, an opening off the interval grid, a boundary that is not opening plus interval), not the newest completed candle at the check's cutoff, from a cutoff older than one interval or in the future, older than the newest observed candle, inside the upgrade bootstrap, already consumed for that side, or while the strategy has an unresolved live attempt.
A confirmed target-side opening trade consumes the candle, including a partial fill and an opening that an immediate stop closed at once; a paper sizing or lot hold, a rejection and a verified not-sent order consume nothing, so a later check of the same candle may retry.
Consumption is written with the book in the owning file's save transaction, and the opening trade of an admitted entry waits for that transaction instead of the eager trade insert.
**Reversals.** A signal that would flip an open position closes it through the existing close-only path (as `close_fraction = 1`; a guarded OKX perps reversal close is sent reduce-only) and never sends the opposite opening leg in the same check.
The opening leg is taken on a later check of the same candle once the book is flat, if the allowance and every gate still permit it, so a held target side never blocks the close.
**Live attempts.** Before a live Hyperliquid or OKX entry order, the scheduler reserves the attempt in `entry_order_intents`, commits the `submitting` transition immediately before the order subprocess starts, and passes the saved client order id to the venue (Hyperliquid also gets a separate initial-stop client id and a signed order deadline 2 minutes out, 3 minutes for the stop; OKX gets the `expTime` request header 2 minutes out through `--exp-time-ms`; a late arrival is rejected by the venue in both cases).
A confirmed fill books the position, consumes the candle and marks the attempt `committed` in one transaction.
An explicit venue rejection or a verified not-sent result releases it as `no_fill`; any other outcome (a timeout, missing or malformed output, an unconfirmed OKX fill) keeps it held and is never resent. An OKX create error counts as a rejection only for a ccxt class raised before the order exists (insufficient funds, bad request, authentication, missing arguments, invalid order other than a duplicate client id); any other exchange error reads the order back by `clOrdId` and books or releases from that evidence, else holds. A guarded OKX entry books only a finite positive fill read back from the order; it never falls back to paper sizing.
**Recovery.** Each cycle starts by recovering held attempts before any dispatch: a `reserved` row was never submitted and is released; a `submitting` or `settling` row is resolved from venue evidence only (Hyperliquid `orderStatus` by client id plus order-matched `userFillsByTime`, after the signed deadlines pass; OKX `fetch_order` by `clOrdId` plus `fetch_order_trades`).
A terminal order with complete matched fills is booked once from the book the attempt started from, with the initial stop adopted or its immediate fill booked; a terminal zero fill is released unless saved execution proof or a client-id fill contradicts it; an open order, an incomplete fill history, a client id unknown before its deadline or a changed book stays held with a logged reason and one owner DM. A scale-in whose on-chain size the Hyperliquid account sync already copied into the book (book = before-add size + proven fill) is booked once from the saved pre-add size and average cost.
**Absence proof (#1771).** A client id with no order is released only when every check passes: the attempt's saved account key (`hyperliquid:<network>:<address>`, or `okx:<inst>:<network>:key-<16 hex of SHA-256 of the API key>`) matches the configured account and the account the script queried; the saved entry deadline (and, with an initial-stop client id, the stop deadline) plus 2 minutes has passed; the saved request is readable with a submission time before its deadline; no saved confirmed or booked quantity, venue order id or fill evidence exists; and the order lookup and every client id (entry and initial stop) report no order, no fill and a complete fill history from before the attempt window through the release deadline.
Both scripts take `--since-ms` (window start) and `--until-ms` (the release deadline); the reported coverage ends at the earlier of the release deadline and the request time.
Hyperliquid reads every `userFillsByTime` page from the window start to the request (a page shorter than 500 rows ends the scan; a longer one is followed until a page adds no new fill) and attributes a fill without a `cloid` through `orderStatus` by its order id only when the fill is at or before the release deadline; later fills cost no lookup, a later fill carrying the client id is still a match, and a found order (such as an initial stop that fires later) still sums every fill by order id.
10,000 or more fills since the window start (the venue retains only the latest 10,000, so the window may be gone), a fill with no time, a page that does not advance, an unretained order or more than 40 lookups inside the window is incomplete.
OKX reads `trade/fills` (last 3 days) or `trade/fills-history` (3 months) by `billId` page up to the release deadline and matches `clOrdId`, even when `fetch_order` returns `OrderNotFound`; a window older than 3 months, a row with no `clOrdId` field, more than 50 pages or a failed fills query is incomplete.
A fills query failure never blocks booking an order that `fetch_order` finds with complete trades.
It does hold a terminal zero-fill release, because the client id cannot be cross-checked (`query_error` in the coverage).
A missing order, a flat position, retention loss or a partial response alone never releases an attempt. Every transport, subprocess, parse or venue error refuses, even when a result was also printed, and each query has a 45-second deadline. An attempt saved before #1771 has no account key, so absence never releases it.
A proven fill that cannot be booked (the book changed) saves its quantity and evidence (`filled_unbooked`) before the hold, so a later empty result cannot release it.
**Serialization.** One per-strategy attempt exclusion covers the live entry path (reservation through settlement), cycle recovery and the operator command. Everyone takes it with a non-blocking try, before `mu`, never while holding `mu`, and never holds `mu` across a venue query. Recovery and the command recheck membership, status, request and evidence under `mu` after the query; a stale result changes nothing (no booking, no hold update, no boundary move).
**Operator release (`/go-trader-resolve-entry <strategy> <attempt>`, owner DM only).** It requests a fresh resolution check for one attempt. It refuses an unconfigured, paper or non-Hyperliquid-perps/OKX strategy, an attempt of another strategy, unavailable or read-only storage, another resolution in progress, a held attempt whose state-file row differs, `reserved` (recovery releases it), every `settling` attempt (a proven fill, partial or not), saved execution proof, any failed absence check above, and an attempt that changed during the query. The only transition is `submitting` → `no_fill`, in one conditional transaction in the owning file that matches the checked row exactly. The hold is dropped only after it commits; a failed commit keeps the durable and in-memory hold and is safe to retry. A repeated request reports the attempt as already resolved and changes nothing.
The row is kept, and `evidence_json` records outcome `operator_verified_absent`, the operator id, the prior status, hold reason and evidence, the account, both client ids, both deadlines, the release time and the order and stop fill coverage. Confirmed and booked quantities, trades, positions, cash and the consumed and bootstrap candle boundaries are untouched; the next entry still passes every gate.
**Future work.** No command repairs a fill booking after the book changed, and none resolves an attempt whose venue history stays incomplete; such an attempt stays held until recovery finds conclusive evidence.
**Upgrade bootstrap.** A strategy loaded from state with no entry record for its symbol and interval holds the first candle it observes, for both sides, even when that check's signal is zero, and saves that boundary; it never invents a consumed candle from a trade time. A new strategy admits its first valid candle.
Records survive restarts, saves and permitted reloads; live, default paper and each named paper source keep separate rows, and storage aliases resolve inside the per-file handle.
A replay source's guarded entry publishes its replay events only after the settlement commits, each with a stable `source_entry_event_id`.
**Backtest agreement.** The backtester takes one entry per decision row, so this rule makes repeated runtime checks match its entry count per decision candle; prices, fees, funding and fill timing still differ.

**Contract.** Go sends `--closed-bar-decisions` (and `--decision-regime-timeframe=<tf>` when regime is on) and requires the `closed_bar_decision` block back; a missing or unexpected block is a script error that holds the signal.
In a Hyperliquid batch the slot carries `closed_bar_decisions: true`, mixed slots share the raw frame and each selects its own view, and shared-state failure falls back to per-strategy checks in the same cycle.
With the flag off the argv, the batch slot and the check output (apart from its generated timestamp) are unchanged.

## Resting Take-Profit Trade-Through

`resting_tp_trade_through: true` (#1727) changes when a paper Hyperliquid perps strategy books a tier take-profit.
Live places resting reduce-only limits and the venue decides the fill, so a mark that only reaches the tier price does not prove the live order filled.
With the flag a tier fills only when a completed bar since entry traded at least `k` ticks beyond the tier limit rounded exactly as the adapter rounds it (`k = 1`, a constant with no config field). The tier fills in full at the rounded limit; several tiers in one step use the quantity-weighted rounded limits.
The study in `backtest/resting_tp_fill_study.py` measures the rule against venue orders. Its summary records the candle price basis and the cancel-time source as unconfirmed, so `k = 1` is a structural floor, not a fitted value.

**Scope and refusals.** `loadConfig` refuses the flag on a live strategy, on anything other than Hyperliquid perps, on a custom check script, without one of `tiered_tp_atr`, `tiered_tp_atr_live`, `tiered_tp_atr_regime` or `tiered_tp_atr_live_regime` as the close, with `tiered_tp_atr_live_regime_dynamic`, on a replay-mirror paper strategy (it books the live tier price), with `allow_scale_in`, on a timeframe outside the fixed Hyperliquid intervals, and when the check interval is longer than the bar.
The key must be the JSON literal `true` or `false`; a raw-key check refuses `null` and every other type before decode, on load and on reload.
Changing the flag is restart-required. A `close_strategy` change on a flagged strategy with an open position is refused (flatten first).

**Observation policy (shared with the backtester).** The check uses only completed bars at or before its evaluation cutoff; the forming bar never counts.
The entry bar (the bar that contains the position's open time) counts only its close; each later bar counts its favorable extreme (high for a long, low for a short).
Each check scans the completed bars after the position's stored scan watermark (from the entry bar on the first check), so a missed check or a restart heals while those bars are still in the frame. When the frame starts after the first bar it needs, the echo says `coverage: frame_truncated`, and the only possible effect is a missed fill.
There is no cadence guarantee: a late check books at check time, never backdated.

**Frozen geometry.** Under the rule tier prices come only from the risk anchor, the entry ATR and the position regime. A missing entry ATR, or a missing position regime for a regime ladder, holds the tier close; the live ATR and live regime fallbacks never apply.

**Stop-first.** Go builds the stop trigger from the Phase 1 position snapshot. The scan ends at the first new bar whose adverse extreme reaches that trigger, so a tier crossed in that bar does not fill (the backtester's `ohlc_walk` already books the stop first).
Each bar is stop-tested once, on the first check after it completes. The position stores the last scanned bar, the best reach and the Phase 1 trigger of the check that last advanced the scan (`resting_tp_scanned_open_ms`, `resting_tp_reach_px`, `resting_tp_stop_trigger_px` in `positions`). Later checks send the first two as `scanned_through_ms` and `prior_reach_px`, and send as `stop_trigger_px` the looser of the stored trigger and the current one (the first scan uses the current one; no stop on either side means no stop test), so a bar is tested against a trigger that was in force from its open. A trailing stop that ratchets inside a bar, or a stop that tightens later (post-take-profit breakeven), never ends the scan at an earlier bar.
A stop-reached bar advances the watermark but adds no reach, so its crossing never fills and later bars are scanned from the next check; the paper stop itself still books only on the check mark. A held rule does not advance the watermark. `recordRestingTPScan` is the only runtime writer of the three scan fields. An unarmed stop with an active stop owner (`stop_unarmed`) or a trailing stop that can still widen once (`stop_may_widen`) holds the tier close.
Paper stops stay mark-based at check time, so paper and backtest agree on tier closes only on frames where no armed stop is reached, under `ohlc_walk`.

**Contract.** Go sends `--resting-tp-rule-json={v,k_ticks,sz_decimals,entry_time_ms,stop_trigger_px,hold_reason,scanned_through_ms,prior_reach_px}` (one `resting_tp_rule` object in a Hyperliquid batch slot, byte-identical and covered by the batch fingerprint). `sz_decimals` comes from the venue lot metadata; unknown sends `null` and holds. `prior_reach_px` stays `null` until a scanned bar adds reach, even after the watermark moves past a stop-reached bar.
The check requires the `resting_tp_rule` echo back with the same `k_ticks`, `sz_decimals`, `entry_time_ms`, `stop_trigger_px`, `scanned_through_ms` and `prior_reach_px`, plus a next watermark and reach that never move backwards; a missing or different echo, an echo for an unsent rule, a held rule with a tier fill price, or a tier close without `close_tier_fill_price` is a script error that holds the signal. A missing input holds with a `noop:resting_rule_*` reason and never falls back to the mark rule.
The check refuses the rule in live mode. With the flag off the argv, the batch slot and the check output are unchanged.
The flag gives the signal frame bar timing, the decision cutoff and one more lookback row in both the single-check and the batch feed paths; `closed_bar_decisions` keeps its own decision semantics.
Logs: a held rule, `frame_truncated` and a stop-reached bar warn once per position; the paper tier fill line names the bar that traded through.

**Backtest.** `Backtester(resting_tp_trade_through=True)` (default off) applies the same helper per bar; it refuses without `platform="hyperliquid"`, perps, `execution_spec` (the tick grid comes from its `size_decimals`), a tier close, or with the dynamic close or scale-in. `run_backtest.py` reads a paper `--config` strategy's flag and takes `--resting-tp-trade-through` for a live strategy, and both need `--mode single --manifest`. Other `Backtester` callers keep the default. Fees are unchanged (issue 1726).
Each resting tier booking records its winning `resting_fill` in the ledger events (version 3), and `ledger_compare.py` checks every tier close against it (§ Ledger comparison, Resting take-profit fills). The comparison is offline and research-only and changes no runtime path.

## Hyperliquid Batched Signal Checks

Due Hyperliquid perps strategies that share market data run ONE batched signal check instead of one process per strategy. Same decisions, fewer subprocesses, faster cycles. Strategies batch together when they share data platform, symbol, timeframe, OHLCV limit, and ATR method.

Operator-visible behavior:

- Set `GO_TRADER_HL_BATCH=0` (or `off`/`false`/`no`) to disable batching entirely and go back to one process per strategy.
- A batch that fails on the shared market-data step makes **every member spawn its own check the same cycle**, so a batch failure never blanks a close, stop, ratchet, protection sync, or hedge. One alert is raised per group, not per strategy.
- Three consecutive shared-state failures on one group revert it to per-strategy checks and retry the batch every 10 cycles. A success clears the state and re-alerts as recovered.
- A slot whose configuration changed between snapshot and dispatch spawns its own check instead of trusting the batch.

The log line names the group as `platform/symbol/timeframe/limit=N/atr=M`.

### Market feed (`market_feed: websocket`)

Root `market_feed` chooses where Hyperliquid candles come from. `rest` (the default, and what an omitted field means) keeps per-check REST polling and last-run scheduling. `websocket` opens ONE Hyperliquid socket, keeps the candle history and mid prices in the Go process, and hands every covered check a sealed snapshot on standard input. The mode is logged at startup as `Market feed: rest (legacy polling)` or `Market feed: websocket (Hyperliquid perps + manual)`, and changing it is restart-required.

**Scope.** Hyperliquid `perps` and `manual` strategies only. Every other platform keeps legacy polling under both values.

**Seven REST consumers stay outside the feed** and outside the zero-call criterion: the manual-open ATR fetch (`--fetch-atr`), the manual-open regime-trail label check (`resolveManualRatchetRegimeLabel`, which runs the check script off-cycle with no feed context from both the CLI and the UI open paths), the LLM entry-review candles, the UI candle chart, the status-server mid fetch, the liquidation-guard off-cycle mid fetch, and OKX perps checks that fetch their own candles.

**Scheduling.** In-scope strategies become due on epoch-aligned deadlines (`floor(now / interval) * interval`), so live and paper twins on the same cadence share one evaluation identifier and one snapshot no matter how long each check takes. A missed deadline is never replayed: the next cycle consumes only the newest one. Strategies on other platforms keep last-run scheduling. Cadences that differ (a drawdown-accelerated strategy, say) get their own identifier, and the cycle log says so.

**Freshness.** A key is ready once it holds at least 30 bars (`coverage_short` is reported when the venue's history is shorter than the lookback). It is stale when its newest bar closed more than two intervals plus 60s ago, or when a connected socket has sent nothing for that key in `max(2 × interval, 5m)`. Mids expire after 15s. A reconnect gets a 30s grace before silence counts. A sealed snapshot older than `max(60s, interval / 2)` at dispatch holds entries.

**Outage behavior.** A key that is not ready and cannot be recovered yields no candle frame. The strategy still evaluates: signal 0, close fraction 0, price from the freshest verified mid, and a `degraded` reason in the log plus one throttled owner alert per key. Trailing stops, ratchets, protection sync, hedge sync and reconciliation keep running on verified inputs; candle-dependent closes report degraded rather than guessing. Entries are held at both in-scope dispatch sites. With no verified mark either, the strategy is skipped with a CRITICAL line. The operator pause state is never written.

**Accounting funding.** Eligible paper HL strategies (Paper Funding Accrual) add a separate need, `AccountingCoins` (each coin plus its hedge coin), kept apart from the signal funding flags: it never triggers `fundingHold`, never holds a check and never deletes a seal key. The owner keeps a 7-day rolling `fundingHistory` window per coin, refreshes it from the last record minus 3 hours under ledger reason `accounting_funding` (at most every 5 minutes and only once the next hourly record can exist, 60s after a failure; each request leaves two budget slots for other reads), trims it at 7 days, and seals it with coverage `{from_ms, to_ms}`. A changed or vanished record refuses the refresh, keeps the previous coverage and raises `accounting_funding_failed`.

**Never a silent private fetch.** With the feed on, a malformed, stale, incomplete or mismatched payload is an explicit error. Python sets the adapter aside for candle, higher-timeframe and funding reads whenever a market payload is present.

**Counting.** Steady-state evaluations make zero candle REST calls. Bootstrap, reconnect repair and stale recovery are counted separately and printed each cycle:

```
[feed] snapshot=300s/1788592500/1 keys=1 ready=1 stale=0 rest{bootstrap,repair,recovery,correction}=1,0,0,0 steady_candle_rest=0
```

`/status` carries a `market_feed` block with the mode, connection state, generation, last snapshot identifier and per-key readiness.

**Closed-bar correction.** The socket can miss a venue revision of a closed candle, and a healthy key otherwise never re-reads it.
Under `market_feed: websocket` and on a `role: feed` service with `feed.source: websocket`, the owner re-reads every retained closed bar at close+5s, close+20s and close+60s.
One `candleSnapshot` read starts at the oldest bar with a due checkpoint, and checkpoints that fall due within 5s of each other share a read.
Reads run beside the seal deadline, so correction never extends it, and a sealed snapshot keeps its bytes and hash; only later seals carry a corrected bar.
Against a socket-stored bar, REST replaces it only when its volume is not lower (volume only grows within a bar), so an older venue read never overwrites newer socket data, and a socket bar never lowers a REST bar's volume.
That rule is an assumption until host captures confirm it: a lower-volume REST revision against a socket-stored bar is rejected by correction and shows as `rest_older`, then overdue, then `unverified`.
REST-versus-REST keeps newer-read-wins ordering, including lower-volume revisions, so the volume rule does not change how the REST backup orders its own reads.
A read that returns no candles, an invalid response, a timeout or a budget refusal leaves the checkpoint pending and retries after 2s, doubling to 30s.
An HTTP 429 from the venue pauses every correction read for 60s (`rate_limited`), because the venue limit is per IP.
A read that returns no newer value for a due bar counts as a failure.
Supported window: a revision that lands by close+60s is corrected by a successful read.
The supported bound is the final checkpoint plus the 5s coalescing delay plus one successful read: close+65s on a 1m key, where the previous bar's final checkpoint always waits for the next bar's close+5s read, and close+60s where no checkpoint coalesces.
The bound holds only while reads succeed and the correction cap is not reached.
A bar whose final checkpoint is still unconfirmed 60s later logs one `correction_overdue` line per key and shows in `/status` (`market_feed.correction`: `reads`, `retries`, `failed`, `refused`, `capped`, `rate_limited`, `corrected`, `added`, `rest_older`, `unverified`, `pending`, `overdue`, `max_reads_per_minute`, `window_reads`, `paused_until`; per key `correction_pending`, `correction_overdue`, `correction_error`).
The correction status block is present only while the correction loop runs; REST backup owners omit it.
Overdue correction never changes key readiness.
After 10 minutes past the final checkpoint the owner stops retrying that bar and logs `status=unverified`; when the overdue episode then ends, the key logs `status=abandoned`, and `status=recovered` only when every overdue bar was confirmed by a read.
Each actual change logs `[feed-correction] status=corrected` with the key, open time, changed fields, prior source and delay.
Every read is counted in the request ledger as `correction` (first attempt) or `correction_retry`; a standalone scheduler installs an unlimited ledger for this, and a `role: feed` service uses its own.
Load is about two to three reads per key per bar interval on a healthy venue (a stubbed 10-minute run gave 2.1 reads per minute for a 1m key and 3.5 per 5 minutes for a 5m key), plus retries after failures.
Each owner holds a hard cap of 20 correction reads per rolling 60s across all keys, in both deployments and whatever the request ledger enforces.
Due keys are served oldest checkpoint first; past the cap a key stays pending (`status=deferred`, counted in `capped`) and can become overdue.
Hyperliquid documents a 1200-per-minute REST weight limit per IP shared with order traffic, with `candleSnapshot` at weight 20 plus more per 60 returned bars, so the cap holds correction to about 420 weight per minute (roughly 35%).
The cap covers about nine 1m keys; more keys show as `capped` and overdue.
A revision after close+60s is outside the window.
`scripts/feed-revision-capture.py` records socket and REST samples for measuring revision delay.
It caps reads across workers at 10 per rolling 60s (about 210 weight); `--max-reads-per-minute` can lower the cap.
Startup refuses plans whose average exceeds the cap or whose peak rolling 60s request count reaches it; accepted schedules leave at least one read of margin for worker start delay.
Defaults capture BTC on 1m and 5m at +1.5s, +5s, +20s and +60s; run other coins in sequential captures.
Report output separates budget refusals from failed requests.
Refused samples are recorded, and HTTP 429 pauses all new reads for 60s with a pause record.
Windows are limited to 50 bars and the anchor interval.
Shared consumers log `correction=n/a`, because wire v1 omits that counter.

### Shared market feed (`role: feed`, `market_feed: shared`)

Separate live and paper schedulers read the same immutable market snapshot from one feed service, so a paper process failure cannot stop the live process and both evaluate identical inputs. Topology: one primary feed service (websocket source), one backup feed service (REST source, running continuously as a warm standby), one live scheduler, one paper scheduler holding the paper deployments as `paper_sources` partitions. Consumers ask the primary first and the backup after a bounded failure. Paper-testing stays separate. This boundary does not isolate the services from a host failure or a feed outage.

**Feed config.** The same binary runs the feed when the config sets `role: "feed"`. The allowed root keys are `role`, `feed`, `status_port`, `log_level`, `discord`, `telegram`, `alert_throttle_interval`, `config_version` and an empty `strategies`; any other key (a state file, a strategy, a risk block) refuses by name. `status_port` is required and bound exactly, with no fallback port. `feed` holds `source` (`"websocket"` for the primary, `"rest"` for the backup), `socket_path`, `consumer_configs` (the scheduler configs it serves; relative paths resolve against the working directory) and `request_budget` (`{"per_minute": N, "startup": M}`; required and enforced for `rest`, optional and only logged for `websocket`). The feed opens no state file, takes no trading lock and never runs a check script; `--once`, `--summary` and `--leaderboard` exit 2. See `scheduler/config.feed.example.json` and `scheduler/config.feed.backup.example.json`.

**Consumer config.** A scheduler sets `market_feed: "shared"`, `shared_market_feed.primary_socket` and, for failover, `shared_market_feed.backup_socket` (absolute, clean, and different from the primary). It owns no websocket and makes no candle fetch for covered strategies; every Hyperliquid perps and `manual` check still reads one sealed snapshot through the unchanged stdin payload (`v:2` batch envelope, market payload v2 since #1665, 8 MiB cap).

**Requirements.** The feed loads each consumer config read-only and without live-credential checks, derives each one's keys with `deriveFeedRequirements`, and serves the union: each key at the highest lookback any consumer asks for, the union of mid coins, and funding needs combined per coin. Strategy maps stay inside each consumer, so two consumers may reuse a strategy id. A consumer is skipped with an owner DM when it does not load, is not in shared mode, or does not name this feed's socket; zero loadable consumers exit 78.

**Schedule.** A seal key is a deadline instant in Unix seconds. The feed schedules every cadence its consumers can select: each feed-scoped strategy's `interval_seconds` (else the root value, else 60) plus the 90-second drawdown-warning cadence for any strategy whose interval is above 90. It seals the whole union at `deadline + 5s` (settle delay) with a 20s preparation budget for stale-key recovery and funding, and stores the bytes only until `deadline + 27s` (hard deadline). Seals run on schedule whether or not a consumer asks. `describe` publishes the cadences, first deadline and timing values. Sealing starts at the first deadline after the initial generation publishes (or after the 120s startup budget). A restarted feed never serves a key from before its own start, and every reply names the feed instance (`host-pid-startms`).

**Seal v3.** A seal that carries accounting funding (`accounting_funding`: coin, `from_ms`, `to_ms`, records, `fetched_at_ms`, `error`) is seal version 3; describe adds `accounting_funding` and `accounting_window_ms`. The union, the size estimate and the consumer coverage check carry the need, and a consumer reports `accounting funding <coin> not served` as a gap. A config with no eligible paper strategy keeps byte-identical v1/v2 seals and describe. A consumer built before v3 refuses a v3 seal, so the feed and every consumer must run the same SHA (`scripts/update.sh --restart`).

**Seal contract.** A seal is canonical JSON (keys, mids and funding in sorted order); its SHA-256 hash travels beside it, never inside it. Stored bytes are immutable: a second attempt for a key keeps the first, and a key past its hard deadline with no seal answers `unavailable` from then on. A late request gets the retained bytes. Each cadence keeps its last three sealed deadlines; older seals are evicted and logged (`[feed-seal] status=evicted`). An evicted, missed or pre-start key answers `unavailable`, never current data under an old key. A consumer measures decision age from the deadline and from the seal time and uses the older (`max(60s, interval/2)` holds entries).

**Backup source (`source: "rest"`).** The backup runs continuously with the same consumer union, schedule, settle delay, preparation budget, retention and immutable-key contract as the primary.
It opens no websocket.
It bootstraps every key over REST, then does all network work inside each seal's preparation window: at deadline D it refreshes each key whose cadence set contains a cadence that divides D (the due keys), then the funding refresh (5-minute freshness) and last one `allMids` read, so the sealed mids are the newest input.
A key's cadence set is the union of the cadences of every consumer that reads it, because a consumer can move a strategy to any of its own cadence keys after a skipped deadline.
Signal keys refresh before higher-timeframe and regime keys; a key that is not due is never fetched, so no request goes to an input no consumer reads at D.
A due refresh that fails is retried once after 1s when the preparation window allows. **A backup seal marks a key ready only if the key was refreshed for that deadline.**
A due key whose refresh fails or is refused is sealed not ready (`failed` or `budget_exhausted` with the deadline in its detail), and a key that was not due is sealed not ready as `not_due` in that seal only (the feed's own state is unchanged).
A consumer whose cadences changed by SIGHUP before the feed reloaded, or that the feed keeps `Retained` with old cadences, therefore holds entries on such a key instead of trading on a closed bar with a partial value.
The next successful refresh makes a failed or refused key ready again.

**Request budget.** Every Hyperliquid `/info` request of a feed process (candle snapshots with each history-widening pass, funding scalar and funding history pages, `allMids`) passes one ledger (`market_feed_budget.go`), which counts it by reason (`bootstrap`, `refresh`, `recovery`, `retry`, `funding`, `mids`, `repair`) and request type.
For `rest`, the ledger enforces `per_minute` over a rolling 60s window and a `startup` allowance that only bootstrap requests draw on, refilled at startup and at each reload; bootstrap past that allowance uses the window.
Key refreshes and funding leave one request in the window for the mids read.
A refused request never blocks: the key is sealed `budget_exhausted`, refused funding stores `request budget exhausted` as its error (funding strategies hold), and refused mids age out (the consumer uses its REST mark fallback).
One `**MARKET FEED REQUEST BUDGET EXHAUSTED**` owner alert fires per episode and one `**RECOVERED**` alert when a seal is prepared with no refusal.
The budget values must come from a measured baseline of today's consumers (see Measured values); a websocket feed counts and logs without refusing.

**Transport.** One Unix stream socket per feed (mode 0660, under the unit's `RuntimeDirectory`), one length-prefixed request per connection: `describe` or `snapshot <key>`.
Replies are `sealed` (header plus seal frame), `pending` (with retry and give-up times), `unavailable` or `error`.
Wire v1 and the seal versions are independent of the stdin payload versions.
A seal that carries open-interest observations (#1637) is seal v2; every other seal, and every reply header of a feed with no observation coverage, stays seal v1 with the v1 bytes, so a v1-only consumer keeps reading a feed that serves no open-interest strategy during a feeds-first update or a rollback.
A consumer accepts seal v1 and v2, refuses a v1 seal that carries observations or a v2 seal that carries none, and requires the seal body version to equal its reply header.
A seal that carries trade flows (#1638) is seal v4 whether or not it also carries open interest or accounting funding; a seal without trade flows keeps its v1, v2 or v3 bytes, so a consumer that predates v4 keeps reading every feed that serves no trade-flow strategy, and refuses a v4 seal or header.
A seal that carries macro calendar or quote sections (#1654) is seal v5 whatever else it carries; every other seal keeps its earlier bytes and version, and a consumer that predates v5 refuses a v5 seal or header.
A seal that carries volume profiles (#1665) is seal v6 whatever else it carries; every other seal keeps its earlier bytes and version, and a consumer that predates v6 refuses a v6 seal or header.
A seal that carries `l2_books` sections (#1635) and no `perp_premiums` is seal v7; a seal that carries `perp_premiums` (#1639) is seal v8 whatever else it carries (including `l2_books`, volume profiles and macro sections). Every other seal keeps its earlier bytes and version, and a consumer that predates v7 or v8 refuses that seal or header.
The market payload version (`marketSnapshotVersion` in Go, `MARKET_PAYLOAD_VERSION` in `shared_tools/market_payload.py`) is 4 since #1639 (3 since #1635, 2 since #1665). One version carries the optional `l2_books` and `perp_premiums` sections; the Go builder and the Python validator change together and refuse any other version. A consumer requires exact payload-header agreement with every peer, so a payload-3 feed is refused even when it serves an older seal form.
Requests are capped at 4 KiB, headers at 1 MiB and seals at 64 MiB, each checked before allocation.
A consumer rejects an unknown field, a non-canonical seal, a hash or header mismatch, or a version it does not speak.
The feed holds `<socket>.lock` for its lifetime (exit 79 when another process holds it) before it removes a stale socket, so a running peer's socket is never replaced.
Each connection has a 10s deadline and at most 64 run at once.

**Consumer cycle.** Shared cycles use one deadline each.
When strategies are due on different deadlines, the earliest runs first and the rest run in the next cycle.
A consumer never requests a key after its give-up time (`deadline + 32s`).
When a strategy's own deadline is past that time at cycle time (after a restart, or after a long cycle), the consumer logs `[feed-audit] key=<k> status=skipped` once for that key.
The strategy then runs on the newest key of this consumer's cadences that is still before its give-up time, or it waits for the next such key, so an old seal is never evaluated.
The consumer asks the primary for that key and waits through `pending` up to the primary's own hard deadline, bounded by `deadline + 29s` (the give-up time less a 3s backup reserve, applied as a connection deadline so a hung socket is bounded too).
A fetch that starts after that bound still gets one probe of the primary, up to 2s and never past the give-up time, so a primary that already holds the sealed key serves it; a `pending` reply then fails over at once.
An endpoint is never asked once the give-up time has passed.
A `pending` reply inside the bound is a wait, never a failure.
After a transport error, an `unavailable`, `error`, incompatible or malformed reply, or `pending` past the bound, it asks the backup for the same key, waiting up to `deadline + 32s`.
No endpoint ever rebuilds a missing historical seal, so a key the backup missed or evicted is degraded.
`**SHARED MARKET FEED FAILOVER**` fires when the backup serves a key and the previous served key came from the primary, or no key was served since startup or the last outage; it names the primary's error and the backup's source, instance, generation and hash; `**SHARED MARKET FEED PRIMARY RESTORED**` fires when the primary serves again after the backup.
Every key tries the primary first, so the return to the primary is automatic.
The consumer verifies the hash and canonical form and checks coverage: a key missing from the seal or below this consumer's lookback is removed, so its strategies hold entries (one `COVERAGE GAP` alert per change).
With no compatible seal it builds a non-nil degraded snapshot: entries hold, covered checks never fall back to private candle reads, and closes, stops, ratchet, protection, hedge sync and reconciliation continue on verified inputs; with no verified mark the strategy is skipped, as in websocket mode.
One owner alert fires per outage transition and one per recovery.
A fetch that ends because the consumer is stopping logs its degraded key with a `the consumer is stopping` reason and sends no alert.
A sealed mid is a valid mark only while its receive time is at most 15s before the current time, so a seal consumed late never supplies old marks.
A covered strategy that has no such mark and no REST mark is degraded and skipped for that cycle, and its batch group falls back to per-strategy checks, so a check script never prices from an old sealed mid.
The manual close evaluation receives the same marks.
Coins without such a mid use the REST mark fallback.
It can supply check prices and degraded results as well as valuation, and each use is logged as `[feed-audit] key=<k> mark_fallback=<coins>`.

**Open-interest observations (#1637, research-only).** A consumer strategy whose open strategy is `open_interest_breakout` adds an observation need for its coin: a window of `oi_lookback` bars (default 4) times the bar interval plus `max_observation_age_ms` (default 120000) plus one minute.
Only a `websocket` source collects it (the `activeAssetCtx` subscription).
A `rest` feed source drops only the observation need and keeps serving that consumer's candle, mid and funding keys (owner DM, `notice` in feed `/status`); the consumer sees the missing window as a coverage gap and its open-interest strategies hold entries.
A scheduler on `market_feed: rest` refuses the config.
The owner keeps the latest sample per one-minute bucket by receipt time (the venue sends no event time), marks a disconnect as a gap from the last sample (with its detection time) that the first sample of the next session closes, rejects non-finite, negative and backwards-clock records, and trims to the largest window plus two minutes.
A seal copies each needed window with the gaps detected by its deadline, cut at that deadline, and `describe` lists it; the evaluator counts a gap only when it was detected at or before the bar close, so a backtest never uses a disconnect the live decision could not yet see; a consumer reports a missing or short window as a coverage gap.
The payload carries it as `observations["<coin>|open_interest"]` with the bar interval and row-endpoint offset.
A missing, stale, gapped or thin window never holds the candle frame: closes, stops and protection continue, and the Python evaluator gives a reasoned entry hold.
The strategy is `edge_status=no_edge` (`study_inconclusive`): it runs with explicit `--mode=paper` and needs `allow_no_edge: true` for live use.
`go-trader record-observations --coins <list> --out-dir <empty dir>` records the same stream into hashed JSONL segments for offline study (`backtest/observation_replay.py`).

**Trade-flow observations (#1638, research-only, production-refused).** A consumer strategy whose open strategy is `cumulative_volume_delta_divergence` adds a `trade_flow` observation need for its coin: `session_hours` (default 4) hours plus one bar plus one minute.
Only a `websocket` source collects it (one `trades` subscription per coin on the existing socket); a `rest` source drops only the need, and `market_feed: rest` refuses the config. Config admission refuses the strategy itself in every mode, so no production config reaches this path today.
The owner folds trades into exact per-minute buy/sell aggregates by venue block time (8-decimal fixed point, `side` B = buyer aggressor). Identity is venue, namespace, coin, block time and tid (the venue documents tid as a 50-bit hash of the two order ids): an identical duplicate is idempotent, a conflicting duplicate invalidates both minutes.
Coverage starts at the first minute after the subscription acknowledgement. A minute becomes final at end + 5 s with a later frame for the coin; a disconnect, a 30 s frame silence (coverage resumes on the next frame), a malformed element or frame, a future-dated trade, a receipt-clock regression or an identity-store overflow invalidates the open minutes, and a late trade for a final minute revokes it with its detection time. Missing minutes never count as zero.
A seal copies the minutes final by its deadline with their status as of that deadline (seal v4, strict decode of the contract, decimals, tallies, hashes and finalization times); `describe` lists the key with source `hyperliquid_ws_trades`; the payload carries `trade_flows["<coin>|trade_flow"]`. Trade flow never holds the candle frame.
The evaluator also requires each used bar's exact minute total to equal its candle volume. `record-observations --kinds trade_flow` records raw frames with per-trade outcomes and every coverage, interval, invalidation and tick event (run/segment/recording schema v2; open-interest-only runs keep v1); `observation_replay.py import-trades` replays a run independently and refuses it when any outcome or event differs.
**BTC leader frames (#1640, research-only, production-refused).** A consumer strategy whose effective open strategy is `bitcoin_lead_lag` adds a candle requirement for `BTC` on its own signal timeframe (`feedStrategyRequirement.Leader`/`LeaderLookback`, `bitcoin_lead_lag_feed.go`): `training_bars + 2*lag_bars + 2*horizon_bars - 2` closed rows plus one forming row, with timing, and raises its follower lookback to the same count.
`cycleRequirementsForDue`, `frameSpecs` (one frame when BTC is the follower) and the batch payload (per-key timing) carry it, and the feed union marks it a signal key for REST refresh priority.
A missing leader frame is a `holdFor` hold and the payload builder refuses it, so the whole candle evaluation degrades while verified protection continues.
Python reads both frames only from the sealed payload (`shared_tools/lead_lag_inputs.py` `prepare_sealed_inputs`: one cutoff, `sealed_frame_timings` + `select_closed`, matched boundaries, no gaps or forward fill); a usable follower with unusable leader input holds the entry while the close owner runs.
Config admission refuses the strategy in every mode; a promotion must also refuse it under `market_feed: rest`.

**Macro-event inputs (#1654, research-only, production-refused).** A consumer strategy whose open strategy is `macro_event_breakout` names a calendar artifact (`calendar_path`, `calendar_sha256`) and optionally a quote artifact (`quote_path`, `quote_sha256`) in its open-strategy params; the feed owner needs absolute paths. Calendar records are shared by artifact digest; quote records by venue, namespace, coin and digest. One path pinned to two digests, a relative path or bad params become a diagnostic on that strategy only (an entry hold), and peers keep their requirements; `market_feed: rest` refuses the config. Config admission refuses the strategy itself in every mode, so no production config reaches this path today.
`ApplyGeneration` reads each new artifact once, outside `feedMu`, hashes and validates the same bytes (`macro_event_calendar/v1`, `macro_event_quotes/v1`, owner `shared_tools/macro_event_inputs.py`; the Go reader in `market_feed_macro.go` mirrors its contract) and installs it only with the matching generation token.
A later generation with the same identity reuses the installed records and never rereads the file; a restarted owner refuses changed bytes by digest.
A freeze copies calendar records known by the cutoff and quote records received in `[cutoff - window, cutoff]` (at most 20,000 per seal); no network request runs at seal time.
`describe` lists each input; the payload carries `macro_events["<calendar sha256>"]` and `macro_quotes["<coin>|<quote sha256>"]` (payload version 1 when added; 3 since #1635).
An unavailable input is an entry-only hold (`feedHold.EntryOnly`): the check still runs so closes, stops, ratchet and protection continue.
An artifact identity change is restart-required for the scheduler (`validateHotReloadCompatible`) and the feed service (`macroGenerationReloadErrors`).

**Volume profiles (#1665, research-only, production-refused).** A consumer strategy whose open strategy is `volume_profile_reclaim` adds a `volume_profile` observation need for its coin (window two UTC days) and a timed signal frame.
Only a `websocket` source collects it, on the coin's existing `trades` subscription and ingestion path; a `rest` source drops only the need, and `market_feed: rest` refuses the config. Config admission refuses the strategy itself in every mode, so no production config reaches this path today.
The owner keeps the profile on the coin's trade state: validated accepted trades add exact base size per canonical price to the UTC session accumulator (identical duplicates add nothing); a profile-only coin keeps an internal trade state whose trade flow is never sealed.
A session is final only when all 1440 minutes finalize complete; an invalid or twice-finalized minute, a conflicting duplicate, a new trade for a finalized minute, overflow, more than 16384 prices, a price over 24 characters, zero volume or one price make it unavailable. A revocation after finalization dates the frozen artifact's invalidation.
A seal carries the session before its cutoff, plus the one before that during the first hour of a session (the longest supported bar interval), each with status `available`, `invalidated`, `pending` or `unavailable` and a reason code; at most 16 profile coins per union, 1 MiB of pairs per session.
Strict decode refuses contract or identity mismatches; a malformed session becomes unavailable metadata (`seal_malformed`) without rejecting the seal. The payload carries `volume_profiles["<coin>|volume_profile"]`; a missing profile never holds the candle frame or a batch, and the evaluator returns a reasoned zero entry while the close owner runs.

**Order-book depth (#1635, research-only, production-refused consumer).** The frozen contract is `docs/research/1635-l2-book-contract.md` (hash-pinned JSON beside it). A typed `l2_book` need (source, contract, time and identity semantics, window, capacity, depth) comes from `feedBookStrategyNeeds`; `order_book_imbalance_feed.go` registers `order_book_imbalance` there (window 60000 ms, capacity 256, depth `depth_levels`), with a Go validator that mirrors the Python one through `shared_strategies/open/testdata/order_book_imbalance_param_cases.json`. Config admission refuses the candidate in every mode, so no production config reaches the book need today; needs merge per key by maximum and refuse different semantics, more than 16 keys, or a set over the joint seal (16 MiB), envelope (4 MiB) or memory (64 MiB) estimate.
Only a `websocket` source collects it (`{"type":"l2Book","coin":C,"fast":false}`, native precision, acknowledgement checked; subscriptions are tracked by key state, so a key released and tracked again before the next sync unsubscribes and subscribes again); a `rest` source drops only the need, and `market_feed: rest` refuses the config.
The owner keeps distinct canonical snapshots (identity = length-delimited SHA-256 of host, namespace, coin, event time and all levels) up to the need depth, with outcomes `accepted`, `duplicate`, `conflict`, `out_of_order`, `rejected`, `uncovered` and `untracked`, and markers for epoch start, subscription, disconnect, invalid frames, conflict, ordering, clock regression, silence over 10000 ms (timer and freeze), evidence loss and depth raise; marker times never decrease per key (an earlier detection takes the previous marker's time, mirrored by the replay).
A seal carries the observations and markers known by its deadline with `retained_from_ms`; the payload carries `l2_books["<coin>|l2_book"]` cut to the consumer's window, capacity and depth with `prefix_complete`, or bounded `book_capacity_refused` metadata when it does not fit (the candle frame stays). A missing, short or incomplete book is an entry-only hold, and the Go hold equals `!prefix_complete` of the section built for that strategy's own need (capacity cut included); only a missing or short seal (key, semantics, window, capacity, depth) is a coverage gap, never an incomplete prefix; `shared_tools/market_payload.py` `market_l2_book` is the typed reader.
`order_book_imbalance` (`shared_strategies/open/order_book_imbalance.py`, typed input `shared_tools/book_inputs.py`) decides once per episode at a one-second deadline from the supplied section; `check_hyperliquid.py` passes `l2_book_observations` from the market payload only, the closed-bar branch refuses it (`unsupported_cadence`), and every candle research tool (`run_backtest.py`, `optimizer.py`, `eval_windows.py`, `exit_policy_ab.py`, `ledger_compare.py`) refuses it before data is read; its study runs only on `backtest/book_event_simulator.py` over the public S3 archive (`backtest/book_archive.py`, `docs/research/1635-l2-book-archive-note.md`).
`inventory_market_making` (#1861) registers a book need of depth 20, capacity 256 and a window of the volatility window plus a one-second predecessor plus `max_input_age_ms` (`inventory_market_making_feed.go`); a `rest` source refuses it, 256 observations cannot hold a full window at every source rate, and it is production-refused, so only the offline quote replay reads books for it (Offline quote replay).
`record-observations --kinds l2_book` writes recording schema v3 (`book`, `book_sub`, `book_marker`, `book_need`, `book_release` records); `observation_replay.py import-books` replays it independently (`backtest/book_replay.py`) and `harness-books` compares sealed payloads with the replay. The open-interest, trade-flow and profile importers read v3 runs that recorded their kind.
Recording schema v3 is one shared schema with a typed contract block per kind: a v3 run, segment header and recording carry `book_contract` exactly when `kinds` holds `l2_book` and `premium_contract` exactly when it holds `perp_premium` (the `trade_contract` and `open_interest_contract` blocks stay as in v2). `book_replay.py`, `premium_replay.py` and the open-interest, trade-flow and profile importers refuse a v3 run whose kinds and typed contract blocks disagree or that names an unknown kind; no recorded byte changed, so the frozen #1639 premium recording imports as recorded.
**Quote/oracle premium samples (#1639, research-only, production-refused).** A consumer strategy whose effective open strategy is `perpetual_premium_reversion` needs observation kind `perp_premium` for its coin (`feedStrategyRequirement.Premium`/`PremiumWindow`, window `(lookback_samples + 1) * 60 s + bar interval`, at most 26 h), independent of both funding flags. The websocket owner (`market_feed_premium.go`) subscribes `bbo` per premium coin and `activeAssetCtx` once per coin for open interest and premium together; each consumer parses its own fields, so a malformed field of one kind never blocks the other. Per coin it keeps the latest accepted quote (bid, ask, sizes, order counts, venue time, receipt, session, sequence) and context (oracle, mark, nullable mid, receipt, session, sequence); the context has no source time, so `oracle_event_ms` stays null and freshness is the receipt age only. Receipt times are taken under `feedMu`.
A sample at each 60 s grid time T holds the latest components received at or before T and becomes final when the owner first processes a frame or tick after T: `sampled`, `missing` (no quote, no context, one-sided book, disconnected) or `invalid` (malformed frame, crossed book, conflicting duplicate, out-of-order or future-dated bbo, receipt-clock regression in that interval). An identical bbo resend is idempotent; a conflicting resend poisons that venue time and revokes earlier samples that used it with a dated `invalid_ms`. A disconnect clears both components and opens a gap, so a restart or reconnect rebuilds the window from new samples; readiness needs a contiguous single-session run.
The freeze copies samples in `[cutoff - window, cutoff]` and hides revocations after the cutoff; the payload carries `perp_premiums["<coin>|perp_premium"]` (payload version 4). The evaluator, not the owner, computes premium and every bound. `market_feed: rest` refuses the config and a `rest` feed source drops the need (its strategies hold entries), because deadline-only REST sampling cannot meet the 60 s contract; premium acquisition makes no `/info` request. An unavailable or invalid premium window is an entry hold inside the evaluator; the check still runs, so closes, stops, ratchet and protection continue. Config admission refuses the strategy in every mode, so no production config reaches this path today.

**Reloads.** A feed SIGHUP re-reads its config and every consumer config; an unreadable consumer keeps its previous contribution. The new union bootstraps new keys and publishes a new generation, which applies to deadlines sealed after it publishes. Changing `feed.socket_path`, `feed.source` or `status_port` is rejected; `feed.request_budget` values apply at the reload, and a `rest` config without them is rejected. A consumer SIGHUP re-derives its requirements and re-checks feed coverage with `describe`; a flat-only regime timeframe change stays hot-reloadable. **Reload the feed first**, then the consumer. Until the feed serves a new key, the affected strategies hold entries with a coverage-gap alert, and they recover without a consumer restart once the feed serves it.

**Audit and parity.** The feed logs one `[feed-seal]` line per deadline (key, status, source, instance, generation, hash, bytes, ready keys, preparation time, and the requests and refusals counted while that seal was prepared) and one `[feed-budget]` line per deadline (requests and refusals by reason and type, window use, per-minute limit, startup allowance left, refreshed keys, refused and failed keys).
Each consumer logs one `[feed-audit]` line per evaluated key (sealed or degraded, whether or not feed health changed) and one `[feed-payload]` line per market payload (key, kind, frame specs, coins, SHA-256).
`scripts/feed-parity.sh --feed-unit go-trader@feed-primary --consumer-unit go-trader@live --consumer-unit go-trader@paper [--since ...]` reads each unit's own journal namespace, joins records by key, and fails on empty input, a key of a cadence that a consumer skipped while its audit records listed that cadence as active (a consumer start, logged as `[feed-audit] event=start`, ends a run, so keys missed while a consumer was down do not count), a consumer hash that differs from the feed's for the same instance, or two consumers that built different payloads from one seal and one frame spec.
Degraded keys, skipped keys, keys served by the backup, source-switch races and mark fallbacks are listed as exceptions, and each consumer line counts its keys per endpoint and source.
Pass both feeds (`--feed-unit go-trader@feed-primary --feed-unit go-trader@feed-backup`) so a backup-served key is checked against the backup's own seal.
`--feed-log label=file` and `--consumer-log label=file` read files instead of the journal.

**Source comparison.** Independent sources are not byte-identical, so cross-source equality is measured separately: `scripts/feed-source-compare.sh --binary ./go-trader --primary-socket <p> --backup-socket <b> [--key <k>]` fetches the same key from both feeds with `go-trader feed-fetch` (which verifies each seal's hash) and compares every closed bar in the open-time range both seals hold (open, high, low, close, volume) and every funding record.
A candle key that either seal marks not ready is skipped with a NOTE line, because its last closed bar can be a partial bar from an earlier refresh.
It excludes source labels, receive times, readiness detail, instance, generation, seal time, forming bars, mids and current funding scalars, prints every difference with both values, and exits 3 when any exists.
It exits 1 with an `INCONCLUSIVE` line when no closed bar was compared, so an all-skipped run never reads as a match.
`go-trader feed-fetch --socket <p> [--key <k>] [--out <file>] [--describe]` is the read-only client behind it.

**Request baseline.** `scripts/hl-request-ledger.py proxy --port <n> --label <name> --ledger <file>` forwards `/info` requests to the venue and logs one line per request; `report --ledger <file>` prints per-label totals by type, cold-start and steady per-minute load (mean, 95th percentile, peak) and the summed load.
A measurement binary built with `-ldflags "-X main.hlMainnetURL=http://127.0.0.1:<n>"` (plus `-X main.hlFeedWebsocketURLOverride=wss://api.hyperliquid.xyz/ws` for a websocket process) sends its Go reads through the proxy, and `PYTHONPATH=scripts/hl_request_ledger HL_REQUEST_LEDGER_URL=http://127.0.0.1:<n>` sends the check scripts' SDK reads through it.
The shim never redirects the SDK's `Exchange` (order) client, which signs by comparing its URL with mainnet, and it does nothing, with one stderr line, in a process where `HYPERLIQUID_SECRET_KEY` is set.
Run paper-mode copies of the production consumer configs this way to set `per_minute` from the summed steady peak and `startup` from the summed cold start.
The Python OHLCV cache is shared between processes on one host and private per unit under `PrivateTmp`, so measure on the service host.

**Status.** The feed serves `/health` (`role: feed`, version, pid, instance, generation, last seal key and age; unhealthy after 10 minutes without a seal) and `/status` (owner readiness, the `describe` block and each consumer's load state); both carry `request_budget` (limits, window use, startup allowance left and cumulative totals by reason and type). A consumer's `/status` `market_feed` block carries `mode: shared` and a `shared` object with `served_by` (the endpoint of the last served key), each endpoint's last status and the last key, source, instance, generation, hash and ready-key count.

**Deployment.** Run each service from its own deployment directory and unit, for example `go-trader@feed-primary` (`/opt/go-trader-feed-primary`), `go-trader@feed-backup` (`/opt/go-trader-feed-backup`, socket `/run/go-trader-feed-backup/feed.sock`), `go-trader@live` and `go-trader@paper`.
Both feeds list the same consumer configs, and each consumer names both sockets.
Both shipped units create `RuntimeDirectory=go-trader-%i` (`go-trader-main` for the plain unit) with mode 0750, so the feed socket goes at `/run/go-trader-feed-primary/feed.sock`; consumers run as the same user and connect through their read-only view of `/run`.
Consumer units must not `Requires=` or `BindsTo=` a feed.
An optional drop-in such as `/etc/systemd/system/go-trader@live.service.d/feed.conf` with `[Unit]`, `Wants=go-trader@feed-primary.service` and `After=go-trader@feed-primary.service` orders boot without tying the live service to the feed.
`bash scripts/update.sh --all --restart` discovers active units plus enabled units that are stopped or failed and whose `--config` sets `role: feed`, so a stopped feed is updated and started.
Scheduler discovery stays active-only: a scheduler unit that an operator stopped stays stopped.
It updates every `role: feed` deployment before the schedulers and verifies each one's `/health` version and fresh pid.
A glob-discovered `/opt/go-trader-<name>` with no discovered unit maps to `go-trader@<name>` when that unit is active, or enabled with `role: feed`, and its working directory matches.

**Converting an existing deployment (optional).** Updates never convert a deployment. `market_feed` defaults to `rest`, and the Post-Update Agent Protocol forbids a behavior change without the operator's consent, so a deployment that does nothing keeps its mode on every update. An operator who wants the shared feed runs `sudo bash scripts/shared-feed-convert.sh` in stages (systemd only; exit 2 usage, 10-19 plan refusals, 20-29 stage failures, 30 failed restore):
1. **`plan [--consumer <unit>]...`** is read-only. It lists every scheduler unit (named `go-trader-*.service` units and `go-trader@*` instances) with its user, config, version, `market_feed` and covered strategies. For a selection, it refuses in these cases: a unit that is not an active scheduler, no covered strategy, a binary without the backup feed, a running process that is not the binary on disk, consumers whose scheduler sources differ, and consumers that run as different users. It refuses (exit 19) when `uv` or Go cannot run as root and as the consumer user, and names the fix (Prerequisites). It reads the first consumer's git `origin`, and when git cannot read it, it refuses (exit 19) with the git error, the tree and its owner. It then runs the feed probe over temporary shadow configs and prints the target.
2. **`feeds --consumer <unit>...`** writes shadow copies of the consumer configs, converted to shared mode, under `/var/lib/go-trader/shared-feed/shadow/` (mode 0600, owned by the consumer user, in a 0700 directory; the feed configs are also 0600, because a config can hold bot tokens).
   The feed skips a consumer that is not in shared mode, so the shadows let both feeds start before any consumer changes.
   It repeats the `uv` and Go check, then clones the first consumer's `origin` into `/opt/go-trader-feed-primary` and `/opt/go-trader-feed-backup` and builds each with `update.sh --rsync-from <first consumer dir>`, which gives the same source and the same version as the consumers.
   The build runs as root on a root-owned clone (the rsync copies no owners), then the tree goes to the consumer user; a consumer tree that `go-trader` owns needs no `safe.directory` setting.
   It writes the two feed configs (ports 8190 and 8191 unless taken), links `scheduler/config.json` to each config, copies only `DISCORD_*` and `TELEGRAM_*` lines into each `.env` (created with mode 0600), installs `go-trader@.service` from the built feed tree (the consumers' release) with its journald namespace, and waits for both feeds to seal and to load every named consumer.
   A re-run keeps an existing feed config when it already serves the named consumers.
   When it serves only shadows (no switched consumer), the re-run sets its list to the new selection and records it.
   When it serves switched consumers, the re-run refuses and names `consumers`.
   `--per-minute`/`--startup` apply only to a new backup; a re-run with other values refuses and names `calibrate`.
   When the consumers run as a user other than `go-trader`, for example `root`, each feed unit gets a `10-shared-feed-user.conf` drop-in with `User=` and `Group=`.
   `update.sh` never rewrites drop-ins, so a later `update.sh --restart --unit go-trader@feed-<name>.service` keeps it.
3. **`calibrate [--per-minute <n> --startup <n>] [--baseline-ledger <file>] [--window <s>]`** samples the backup's `/health` `request_budget.window_used` for two periods of the longest cadence. It then writes the fixed operator values, or 1.5 times the measured peak and bootstrap total, and sends SIGHUP. It fails, and restores the previous budget, when any request was refused during the sample, when the peak plus the mids reserve does not fit, when the need exceeds a `--baseline-ledger` cap, or when the script is interrupted or stops early. `feeds` journals its budget as provisional, and `verify` and `switch` refuse until a `calibrate` passes.
4. **`verify [--accept-differences]`** requires both feeds healthy and `feed-source-compare.sh` exit 0. It re-checks a difference or an INCONCLUSIVE result once, on keys newer than the first comparison. Differences that remain stop the stage (exit 25) until the operator reviews the listed bars and re-runs with `--accept-differences`, which records the count in the journal. A bar that the venue revised after the primary sealed it is a known cause, because the websocket owner keeps that bar until it leaves the lookback window.
5. **`switch --consumer <unit> [--confirm-live <unit>] [--dropin]`** handles one running consumer.
   It keeps a byte copy (`<config>.pre-shared-feed.<UTC stamp>`), writes `market_feed: "shared"` and both sockets, repoints both feeds from the shadow to the real config, sends SIGHUP, and waits until each feed's `/status` shows the config loaded.
   It then restarts the unit and requires a fresh healthy pid.
   It waits for three `[feed-audit] ... status=sealed endpoint=primary` lines and requires `feed-parity: PASS` since the restart.
   Any backup-served or degraded key, any failed check, and an interruption (SIGINT, SIGTERM, SIGHUP from a lost terminal, logged to `interrupted.log` in the state directory) or early exit restores the byte copy and the shadow, restarts the unit and verifies its health (exit 26).
   A write to a closed output pipe during a guarded stage starts its undo.
   Every undo (of `switch`, `calibrate` and `consumers`) ignores SIGINT, SIGTERM, SIGHUP and SIGPIPE from its first step until it exits, so a second interrupt, a lost terminal or a closed output pipe does not stop a restore half way.
   Output after a lost terminal is not kept; `status` shows the result (journal, feed budgets, each unit's mode).
   The post-switch fingerprint is journaled right after the write.
   A re-run of `switch` on a shared config whose last switch record did not finish refuses and names `rollback --consumer`.
   A unit with a `--mode=live` or `manual` strategy needs `--confirm-live <unit>` or the unit name typed.
   `--dropin` adds `Wants=`/`After=` on both feeds, never `Requires=` or `BindsTo=`.
6. **`consumers --consumer <unit>...`** sets the full list of consumers that both feeds serve. It adds a new unit (served from a shadow until it is switched), drops units no longer named, and reloads both feeds. It refuses (exit 17), and changes nothing, when a unit it would drop is running or enabled with a config in shared mode on these feeds; a unit a fold stopped and disabled can be dropped. If either feed fails to load the list, or the stage is interrupted or stops early, both get their previous config back (written atomically), and the script checks that each running feed loaded it again (exit 27; exit 30 when that check fails). Use it after a paper fold.
7. **`rollback --consumer <unit> | --all`** restores the byte copy when the config still matches its post-switch fingerprint.
   When the config changed after the switch, for example in a fold, it reverts only `market_feed` and `shared_market_feed` and keeps the changed config as `<config>.pre-rollback.<UTC stamp>`.
   It restarts and health-checks only a unit that is running; a stopped or failed unit gets its config back and stays stopped.
   `--all` covers the current consumers and every unit with a switch that is not rolled back.
   It refuses, and changes nothing, when a current consumer is in shared mode with no saved pre-switch config.
   It then stops and disables both feeds, because `update.sh --all` starts an enabled stopped feed.
   It journals a reset first, so the earlier `feeds`, `calibrate` and `verify` results no longer count: `calibrate`, `verify` and `consumers` need a new `feeds` run, and `switch` needs all three again.
   The calibrated budget stays in the backup config as the starting value, and the new `calibrate` measures it again.

`status` prints the journal (`/var/lib/go-trader/shared-feed/convert.journal`), both feeds and each consumer.
Every stage except `plan` and `status` takes an exclusive lock (`convert.lock` in the state directory) for its whole run, so a second stage started meanwhile exits 18 and changes nothing; the lock ends with the process, on every exit path.
Every stage can be re-run; a finished stage keeps its feed config, and a switched consumer whose config still matches is left alone.
The paper fold (`scripts/merge-paper-instance.sh`) and the conversion are independent, and they can run in either order.
The fold refuses when `market_feed` or `shared_market_feed` differ between the target and a folded source.
Fold before converting, or switch the target and every folded source first.
After a fold on converted units, finish the fold's printed steps, then run `consumers` with the remaining units.
The fold tool needs the template layout (`go-trader@<instance>`, `/opt/go-trader-<instance>`, `/var/lib/go-trader/<instance>/config.json`).
`plan` prints a fold hint when it finds more than one paper unit.
Test hooks: `SHARED_FEED_CONVERT_FAIL_AFTER=feeds-reload|restart|audit` forces the switch restore path, `consumers-primary|consumers-backup` sends the stage SIGTERM after that feed loaded the new list, and `SHARED_FEED_CONVERT_AUDIT_KEYS` sets the key count.

**Measured values.** A 45-minute websocket capture on 2026-09-29 (BTC, ETH, SOL, HYPE, DOGE at 1m, 5m and 15m; 269 closed bars) saw the last update of a closed bar arrive at most 3.9s after the bar's close (99th percentile 2.3s), and 41 bars changed after their close time; the 5s settle delay covers that sample, and a longer sample on the service host should confirm it.
REST reads from the development host took 45 to 230 ms per call (a 500-bar candle snapshot, a three-bar recovery window, one funding page, all mids), well inside the 20s preparation budget.
The decision-age proof: a seal is stored by `deadline + 27s` at the latest, and the entry limit is at least 60s from the deadline, so the check dispatch and the rest of the cycle get at least 33s.
A three-key union (ETH 1m, BTC 1m, SOL 5m at 200 bars) sealed at 62,803 to 65,817 bytes, about 100 bytes per bar, so the 64 MiB cap holds roughly 600,000 bars; the feed also refuses a union whose conservative estimate (160 bytes per bar) exceeds the cap.
With three seals retained per cadence, memory is at most `3 × cadences × seal size`.
Every production consumer's cadences, bytes per seal and the rehearsal on the service host are still to be recorded before cutover.
The backup's `request_budget` has no measured production value yet: the request baseline of the production consumers is measured on the service host with `scripts/hl-request-ledger.py` during the host rehearsal, and the budget is set from it before the backup serves production consumers.

---

## Operator-Required Circuit Breakers

Some venues have no safe automated close path:

| Platform | Type | Pending key |
| --- | --- | --- |
| OKX | spot | `okx_spot` |
| Robinhood | options | `robinhood_options` |

When one fires and the strategy's own book holds a positive leg (the OKX spot position, or each Robinhood option position), the scheduler records `operator_required: true` under the pending key before it closes the virtual book. A fire with a flat own book adds no leg and logs one `[WARN] operator-required-close:` line; an earlier entry keeps its legs, episode and reminder schedule. A later contributing fire merges into the earlier entry that you did not clear: the union of symbols, the sum of the sizes for a symbol in both (each fire closes the virtual book, so a later own-book leg is new exposure on top of the earlier one), sorted by symbol, a new random `episode`, a new `fired_at`, and a reset reminder stamp, even when the legs are the same. Every size is the virtual book at fire time, summed across the fires not cleared. It does not show the current venue position.
The `robinhood_options` row applies only to a pending close saved before #1823, because an options strategy with a live mode no longer loads; such a saved close still loads, renders and clears through the same paths.

Cadence (#1824):

- At the fire, the per-strategy circuit-breaker alert posts once to the channels and the owner DM and lists the legs as "manual flatten required".
- The operator-required drain runs before `CheckRisk`, so its first warning follows on a later cycle with due work. It then repeats once per `alert_throttle_interval` (default 6h), and at once after a new contributing fire. The `[CRITICAL] operator-required-close:` log line repeats on every drain, due or not.
- The drain stamps `last_notified_at` only after a channel post or the owner DM was accepted, and saves it at once. With no notification backend, or when every destination fails, nothing is stamped and the next drain retries. A failed save keeps the stamp in memory and the cycle save retries it; a restart before that save can repeat one warning. A clock that moves backward makes the entry due.
- The stamp, `episode` and `fired_at` live inside `risk_pending_circuit_closes_json`, so a restart keeps the schedule.

```bash
curl -s localhost:8099/status | uv run --no-sync python -c "
import json, sys
d = json.load(sys.stdin)
for sid, s in d['strategies'].items():
    pc = s['risk_state'].get('pending_circuit_closes') or {}
    for platform, p in pc.items():
        if p.get('operator_required'):
            legs = ', '.join(f\"{x['symbol']} size={x['size']}\" for x in p['symbols'])
            print(f\"{sid} [{platform}]: {legs} episode={p.get('episode')} fired_at={p.get('fired_at')} last_notified_at={p.get('last_notified_at')}\")
"
```

Response: open the venue UI, flatten the listed positions, check the venue position, then send `/go-trader-clear-operator-close <strategy> [platform]` in a DM to the bot and reply `confirm`. The command does not read the venue. Only two paths clear an entry: this command, and a portfolio kill-switch fire, which hands the manual check to the kill-switch flow (the kill-switch plan flags OKX spot and Robinhood options and blocks an owner-less auto-reset). Circuit-breaker expiry, a restart and a flat book never clear it. The drain and the command read only the running state, so a `robinhood_options` entry stays shown, reminded and clearable even if the strategy config changes.

This is not the portfolio kill switch. Operator-required is per-strategy and affects only the strategy that breached drawdown.

---

## Cash Reconcile Latch

A live spot fill that overshoots virtual cash is always booked, because the venue already filled it. The strategy then latches `CashReconcileRequired`, raises a CRITICAL alert, and blocks further live buys. Closes keep running. Clear it with `/go-trader-clear-cash-reconcile <strategy>` only after the books match the venue — the command drops the buy block and never invents or adjusts cash.

---

## Implementation Patterns

Full coding constraints live in [CLAUDE.md](CLAUDE.md) § Patterns. Notes that bite most often:

- A new trade-recording path must populate `Trade.PositionID`, or rely on the recorder's lookup against the strategy's positions, so partial closes collapse into one round trip.
- A new summary-posting path must thread the last-post map and call the shared cadence helper.
- Category-summary row labels use a fixed-width label helper; assert the exact text in tests.
- A new side-effecting subprocess wrapper goes through the side-effect runner, never the plain runner.
- Hedge PnL is recorded through the hedge recorder, never the ordinary trade recorder.
- A new per-strategy flag needs the `StrategyConfig` field, the `run*Check` CLI argument, the Python parse in the check script, and `InitOptions`/wizard support. A runtime-required flag also goes into both probe argvs.

Audits:

```bash
grep -n "mu\.\(R\)\?Lock\(\)\|mu\.\(R\)\?Unlock\(\)" scheduler/main.go
grep -n "liveExecFailed" scheduler/main.go
```

---

## Subsystem Mechanism Reference

Per-subsystem mechanism notes for coding agents. `CLAUDE.md` keeps only the guardrail for each file; the mechanism behind it lives here. Grouped by `scheduler/` file.

### Execution and fill confirmation (`executor.go`, `shutdown.go`)

- `runPythonSideEffect` is the runner for every subprocess that can place, cancel, or modify an order; `runPython` is read-only. Graceful shutdown drains side-effecting subprocesses for at most `shutdownDrainCap=15s` and then SIGKILLs; state save, notifier flush, and DB close run afterwards through deferred LIFO. `TimeoutStopSec=20` in the units.
- `confirmHyperliquidExecuteFill` covers open, close, scale-in, hedge, manual, and UI paths. A response without a confirmed fill returns `"exchange returned no confirmed fill"` and books nothing. `hyperliquidExecuteSucceededCancelOIDs` retains the confirmed cancel OIDs so protection reconciliation can tell a cancelled trigger from a lost one.
- Bidirectional perps: `bidirectionalPerpsStrategies` mirrors the open registry `short_entries=True` set (`short_entry_strategies()`); `scripts/test_go_python_registry_parity.py` fails on a name missing from either side. Flip sizing uses `perpsLiveOrderSize`.

### Shared OKX step (`okx_step.go`, #1870)

- `runOKXStep(c okxStepContext, sc, check okxCheckFunc) okxStepResult` is the only OKX gate sequence. The main loop calls it from the OKX spot branch and the OKX perps branch, both with `okxOrdinaryCheck`. No production host calls it with `okxPairManagementCheck` yet: research management (#1881) will, and today only the `pyintegration` tests use that check. Direction comes from `sc.Type`: spot is long-only (`true, false`), perps uses `PerpsAllowsLong`/`PerpsAllowsShort`. Any other type, another platform, a nil check, a missing configuration, state, store, database, lock, price map or partition risk, or a pair member with no coordinator refuses the step before the check runs; there is no long-only fallback.
- The caller keeps everything before dispatch: the read-locked capture of position, side, quantity, average cost, leverage, cash, pool-balance availability and the cash-reconcile flag (before `CheckRisk`), `CheckRisk` and its notifications, cycle-level entry recovery, and the persistence-hold read. The caller also keeps everything after: channel totals, `sendTradeAlerts`, evaluation marks and the cycle save. The step returns trades and detail.
- Inside the step the order is unchanged from the two former branches: invalid pair observation, check, price publication, first member-increase suppression, validated observation, regime payload, closed-bar decision log, regime gate, Hurst evaluation, closed-bar hold, higher-timeframe hold, Hurst hold, pause, daily loss, notional, persistence and exposure holds, closed-bar admission with `CloseReduceOnly`, second suppression, locked regime sync, live intent reservation and submit gate, `runOKXExecuteOrder` with `liveExecFailed` and `finishFailedLiveEntry`, locked `beginEntryBookingLocked`/`executeOKXResult`/`finishEntryBookingLocked`, then the spot cash alert and the locked cash-reconcile reminder. Checks and order subprocesses run outside `mu`.
- Check contract: `func(sc, posCtx, logger) (res, signalLabel, price, ok)`. `okxOrdinaryCheck` wraps `runOKXCheck`; `okxPairManagementCheck` wraps `runOKXPairManagementCheckRaw` (the frozen subprocess; `runOKXPairManagementCheck` keeps its result/stderr/error shape on top of it). Both end in `completeOKXCheck`, which owns script errors and stderr, failure and recovery alerts, closed-bar, pair-echo and higher-timeframe contract checks, higher-timeframe observation, the signal label, a nil-result refusal and the price fallback (finite positive price only). The research fallback map holds only the frozen mark, so a missing price never fetches from the exchange. A failed check returns before price publication, observation, send or booking.
- Clock: admission, live intent reservation and the cash-reminder stamp read `tradingNow()`, as the Hyperliquid step does, so a host that binds the trading clock moves admission and paper settlement together; there is no separate clock field. The live submit deadline in `okxEntrySubmitGateFor` and live recovery keep system time.
- Proof: `TestOKXSharedStepOrdinaryGolden` compares 62 spot and perps traces (book, signal, intent, send, alert and trade-alert tail) against goldens captured from the moved original branches (`scheduler/testdata/okx_step/golden/manifest.json` records the base and source commits); capture refuses once `okx_step.go` no longer matches the manifest. `TestOKXSharedStepPair*` runs the real frozen `check_okx.py` through the step with exchange imports and sockets blocked (`scheduler/testdata/okx_step/offline`). Both are `pyintegration` tests in the `go-python-integration` job.

### Loopback UI and tuning (`server.go`, `ui_*.go`, `static/ui/*`, `ui_tuning.go`)

- `applyStrategyConfigPatch` needs `config_version>=13`. `/tuning` (`static/ui/tuning.html`) is read-and-launch: it re-reads `/api/strategies/<id>/config` on every poll.
- `/api/tuning/runs` is a suggest-only lane. `tuning.max_retained_runs` prunes terminal run directories. `POST /api/tuning/apply` is refused on drift against the schema-v2 `promotion_baseline` and applies through `mutateConfigRoot` as an exact replace.
- **Partition navigation.** Every dashboard read takes an optional `partition` query key carrying the runtime's own partition text (`live`, `paper`, `paper:<source id>`).
  An absent key keeps every partition.
  `uiPartitionParam` (`ui_partition.go`) is the single resolver: unparseable text is 400, and a well-formed partition this process does not own is 404, so a stale selector never reads another deployment's view.
  `/status` carries a `partitions` roster (partition, label, scope, source) in the stable order live, default paper, then sources by id; an unlabelled source reads as its partition text, never the bare id.
  `/api/strategies`, `/api/strategies/overview`, `/api/leaderboard`, `/api/strategies/dead` and `/api/diagnostics` honour the key; `UIStrategy` and `UIStrategyOverview` carry `partition` and `paper_source`.
  Diagnostics filters before paging **and** before the total, through `TradeDiagnosticsRowsPageForPartition`, which resolves the owning file by `SourceRole` (never by scope) and counts only that partition's ids; a `strategy` query outside the selected partition returns an empty page.
  Cash flow stays live-owned (`live_owned: true`) and answers a paper partition with `available: false` and a reason instead of the live wallets under a paper label; the close-evaluator catalogue stays shared (`shared: true`).
  Correlation and portfolio risk are already keyed by partition, so the front end reads only the selected key: under a selection the untagged single-value `correlation` and `portfolio_risk` fields, which belong to the legacy scope, are never a fallback, and the panel reports no reading instead.
  `/status` names a strategy's `partition` only when the roster still configures it, so an orphan state row's circuit-breaker alert is shown under every selection rather than filed under a partition it may not belong to.
  The selector stays hidden while the process owns fewer than two partitions, resets the selection to every partition when it hides, falls back to every partition on any 404 of a partitioned read so sibling reads recover in the same cycle, drops a response whose selection is no longer current, and clears a selected strategy the new partition does not own.

### Config and close defaults (`config.go`, `config_migration.go`, `close_defaults.go`)

- Options paper-only gate (#1823): `validateConfig` appends `strategy[<id>]: ` plus `optionsLiveModeConfigError(<inferred platform>)` (`options_paper_only.go`) for `type: options` with `isLiveArgs`, in the per-strategy loop outside the `skipLiveCredentialChecks` block, so every loader refuses it; `paperToLiveBlockedReason` uses `optionsPaperOnlyReason`, and `check_options.py` ports `isLiveArgs` and emits the same text.
- v20 (#1681) removes `allow_deprecated` and stamps `allow_no_edge: true` only on explicitly live pre-v20 strategies whose whole effective reference set ran live before; `migrateConfigDataReport` returns the stamp list, the loader keeps the first applied report on `Config` (non-JSON), and `runConfigMigrationDM` delivers it when v20 changed legacy input (versionless included). `LoadConfigForProbe` migrates in memory like `LoadConfigReadOnly`.
- v19 renames the per-regime stop fields to `*_atr_mult_regime`; legacy-key presence gates the boot rewrite. The seven HL stop owners are mutually exclusive and all-omitted resolves to `DefaultStopLossATRMult=1.0`. A single `*StrategyRef` is accepted, `close_strategy` canonical.
- `portfolio_risk.paper` is an optional override block with the parent's fields. `scopeRiskConfig(cfg, scope)` merges non-zero override fields over a clone of the parent (zero = inherit). `paper.max_notional_usd` is restart-required like the parent; the other override fields hot-reload.
- Close defaults resolve system → user → strategy. A reserved `regime_atr` section serves standalone `*_atr_mult_regime` `use_defaults` owners. `user_defaults.close["trailing_tp_ratchet_regime"]` may carry `trailing_stop_atr_mult_regime`; `applyUserCloseDefaultRatchetRegimeTrails` applies it inside `loadConfig` before the scalar ATR-stop default so the scalar default never shadows it.

### Portfolio scope and state (`portfolio_scope.go`, `state.go`, `db.go`)

- `hyperliquidModeFromArgs` is a wrapper over `PortfolioScope`. `measureScopeCycleRisk` and `applyScopeCycleRisk` own the per-scope cycle read; `dueStrategiesNotLatched` filters the due set per scope.
- `portfolio_risk` is keyed by `scope` (rebuild migration `migratePortfolioRiskScopeColumns`); `kill_switch_events` and `correlation_snapshot` carry `scope`. Legacy unscoped rows load under `scopeUnassigned`; `placeLegacyPortfolioRisk` puts each file's row into the scope that file owns and the placement is saved at once, so it is idempotent. In a split layout with no live strategy the primary file's unscoped row is rejected instead of guessed.
- `StateStore` (`state_store.go`) owns the physical handles and the immutable identity map, routes every write to the owning file and combines every cross-scope read. `LoadStateWithStore` composes `loadProcessMeta` and `loadScopeBooks` per file, so paper books load with no process-metadata row in the primary. `SaveAll` runs one transaction per file and returns an error per scope; a failed file leaves its scope in memory for retry and cannot duplicate the other file's committed effects. Test hook `storeCommitHook` injects a per-file commit failure. § Storage Ownership.
- `LoadState` bounds per-strategy trades in SQL (`ORDER BY timestamp DESC, rowid DESC LIMIT maxTradeHistory`, index `idx_trades_strategy_timestamp`), separately for funding and non-funding rows, and merges the two windows in that order, so funding rows never push real trades out of the loaded history. Backward scans for the opening trade skip funding rows. `ValidatePerpsDirectionConfig` runs at startup; `CheckStatePresence` is bypassed with `GO_TRADER_ALLOW_MISSING_STATE=1`.

### Risk, latch, and the portfolio gates (`risk.go`, `strategy_interval.go`, `daily_loss.go`, `exposure_cap.go`, `notional_cap.go`)

- `collectPerpsMarkSymbols` feeds `type=manual` positions at live mids. A one-shot `PortfolioRisk.PeakValue` migration runs on first load.
- Latch ownership per cycle: equity drawdown when `equityGuardArmed`, else margin drawdown, with no tie-break. When equity owns the latch, margin drawdown over the limit is a throttled WARN. With `equityTrusted` false the latch stays equity-side, the peak ratchet is skipped, and drawdown is floored at the last reading (`DrawdownReadingSubstituted`). An untrusted over-limit reading defers until `untrustedEquityLatchDeferral` (15m), then latches loudly. Per-scope single-flight flags guard the reset prompt; the prompt names the scope and accepts `reset` for one latched scope, `reset live`/`reset paper` when both are latched. A paper latch calls `forceClosePaperScopePositions` (virtual close at mark, no exchange call), guarded once by `KillSwitchCloseApplied`. Per-position margin protection is the per-strategy circuit breaker (`circuit_breaker:false` opt-out). Full operator behavior: § Portfolio Kill Switch And Latch Ownership.
- Daily loss limit: `portfolio_risk.daily_max_loss_usd`/`daily_max_loss_pct`, 0 = off; both set → the lower resolved USD wins; the pct basis is the sum of strategy `initial_capital` per scope. The gate shape to copy for any new `portfolio_risk` gate: RLock evaluation per scope, `pausedBlocksSignal` holds, `manualStateView` refusals, `clonePortfolioRiskConfig` hot-reload.
- Exposure cap: `portfolio_risk.max_same_direction_notional_usd`/`max_asset_concentration_pct`, 0 = off; `exposureCapBlocksSignal` decides; TopStep futures are ungated. `computeAssetDeltas` in `correlation.go` is the one exposure model shared with `ComputeCorrelation`. Hot-reloadable via SIGHUP.
- Notional cap: `portfolio_risk.max_notional_usd`, 0 = off. `notionalCapSkipsStrategyCycle` is always false: closes, SL, and TP maintenance keep running; manual open, add, and limit-open refuse. Restart-required.
- Kill-switch fill attribution on shared HL coins goes through `hyperliquidKillSwitchFillShare`, which fails closed when the split cannot be determined. The reset prompt is single-flight via an `atomic.Bool`; the deferral timestamp is `UntrustedOverLimitSince`.

### Live-to-paper replay (`replay_log.go`, `replay_mirror.go`)

Enabled by `replay_log_path` plus per-strategy `replay_sharing="live_mirror"`. Paper suppresses its own entries and replays the live decisions. The source id comes from `replayMirrorSourceID` (`replay_source_id`, else the strategy's own id); `orderReplaySourcesBeforeMirrors` runs an in-process source before its mirror in the same cycle. The watermark is keyed on the paper strategy plus `ReplayMirrorWatermarkSource`; a source change resets it with a WARN. Book drift raises WARN plus DM; a close while flat is INFO.

### Batched HL checks and fills (`hl_batch.go`, `hyperliquid_fills.go`, `hyperliquid_balance.go`)

- Batching is a pure partition on `hlBatchKey` into one `check_hyperliquid.py --batch-check`; the fingerprint is re-checked at dispatch. Three strikes revert a group to per-strategy checks with a batch retry every 10 cycles. Operator view: § Hyperliquid Batched Signal Checks.

### Market feed (`market_feed*.go`, `hyperliquid_candles.go`, `hyperliquid_funding.go`)

- `marketFeedOwner` owns one websocket, its own `feedMu`, and a bar ring per `marketFeedKey{Host, Namespace, Symbol, Timeframe}` sized `required + 50`. It never takes the scheduler state lock; alerts leave through a channel the cycle drains outside `mu`. A race test and a source guard hold that line.
- `deriveFeedRequirements(cfg)` builds the key set from every in-scope consumer: signal frames, higher-timeframe filter frames (`hlFeedHTFMap` mirrors Python `_HTF_MAP` through `shared_tools/testdata/htf_map.json`), regime timeframe overrides, mid coins and funding needs. Each key stores the highest lookback any consumer asks for; `snapshot.frameFor(key, required)` slices it per consumer, so legacy row counts are preserved. `ApplyGeneration` bootstraps new keys and publishes a generation only once every required key is ready or has failed explicitly.
- Accounting funding (`market_feed_accounting.go`): `EnsureAccountingFunding` runs after `EnsureFunding` in both prepare paths, fetches outside `feedMu` through `hlFundingRecordsCoverage` (the pager rejects non-finite rates and conflicting duplicates), and swaps the window in under `feedMu`; `freezeAccountingFundingLocked` copies one entry per coin into the snapshot.
- `hlFetchCandleHistory` mirrors the Python adapter's widening loop, and `hlCandleRowFromRaw` mirrors its row conversion including the exchange close-timestamp preference; one golden fixture proves both converters agree.
- Closed-bar correction (`market_feed_correction.go`): `runCorrection` starts inside `Run`, so both websocket deployments share it. `correctionPass` picks due keys under `feedMu`; each read runs outside it and `mergeCorrectionRows` merges under it, touching closed bars only and never `LastRecvAt`. `feedRestBarDecision` orders REST against socket data by volume and `RecvAt`; `feedBar.RestSeenAt` records the newest confirming read and drives the checkpoints. Correction is in-memory, dies with the generation's key state, and restarts populate from the bootstrap read.
- `sealCycleMarketSnapshot` runs at most one REST recovery per stale key per cycle, then freezes a deep copy. Later socket updates belong to a later snapshot. The payload rides stdin: the batch envelope becomes `v:2` with a `market` object; individual and regime-bundle checks get `--market-stdin` and the same envelope. Go caps the payload at 8 MiB and treats a serialization or size failure as a degraded dispatch, never a private fetch.
- Shared feed: `main` peeks `role` before `LoadConfig` and hands a feed config to `runFeedRole` (`market_feed_role.go`), before any storage layout, lock or probe; `LoadConfig` refuses `role: feed` by name.
  `sealCycleMarketSnapshot` is split into `prepareMarketSnapshot` (recovery and funding, network) and `freezeMarketSnapshot` (under `feedMu`); the websocket path still freezes at its cycle-start time, the feed freezes at the real seal time.
  `feedSealer` (`market_feed_server.go`) owns the schedule, the per-cadence retention ring and the hard-deadline rule under its own mutex, runs network work outside it, and never touches scheduler `mu`; lock order is sealer `mu` then `feedMu`.
  `market_feed_wire.go` is the only encoder and decoder of seal bytes; `feedSealDoc.snapshot()` rebuilds the same `marketSnapshot` every payload builder already reads, so batch, individual, regime-bundle and manual checks need no shared-mode branch.
  `sharedFeedClient` (`market_feed_client.go`) returns a non-nil snapshot on every path.
  `TestSharedFeedParity` is the one socket-level Go test; process-level proof uses `scripts/feed-parity.sh`.
- Backup feed and failover: the sealer calls a `feedPrepareFunc`; `websocketFeedPrepare` wraps `prepareMarketSnapshot`, and `feedRESTSource.prepare` (`market_feed_rest_source.go`) refreshes due keys only (`feedKeyDueAt` over `feedRequirements.KeyCadences`), funding and mids, and marks a failed or refused due key `failed` or `budget_exhausted` through `markKeyUnready`, which `keyReadiness` reports as not ready.
  Its report sets `ReadyOnlyRefreshed`, so `sealOne` runs `markUnrefreshedKeysNotReady` on the frozen snapshot and every key not refreshed for that deadline seals `not_due` without changing owner state.
  `feedRequestLedger` (`market_feed_budget.go`) travels in the feed's root `context.Context`; `feedBudgetAcquire` sits at the top of `fetchHyperliquidCandleSnapshot`, `hlPostInfo` and `fetchHyperliquidMidsCtx`, so every widening pass and page is counted and gated, and a spent context returns its error before it takes a unit, and a context without a ledger (scheduler paths except standalone websocket) is unchanged.
  `fetchAndMerge` tags the context with its `feedRestReason`; `withFeedKeep` reserves requests for the mids read.
  The client bounds every endpoint but the last by the give-up time less `feedClientBackupReserve`, and tracks `servedBy` for the FAILOVER and PRIMARY RESTORED alerts.
  The backup changed neither the seal document, the wire header nor the `describe` schema, so a part 1 consumer read a backup seal; #1637 later moved the seal to v2.
  `TestSharedFeedFailover` is the one failover Go test; it also seals a REST key whose cadence does not divide the deadline and checks `not_due`.
- Open-interest observations (`market_feed_observations.go`, #1637): `feedObservationState` per `feedObservationKey{Host, Namespace, Coin, Kind}` under `feedMu`; `ingest` keeps one sample per right-edge minute bucket (same bucket replaces, Seq increments), rejects non-finite, negative and backwards receipt times, and trims to the window plus two minutes.
  `SetConnected` bumps `obsSession` on connect and opens a `disconnected` gap on disconnect (start = last sample, `DetectedMs` = disconnect time); the next accepted sample closes it.
  `freezeObservation` drops a gap detected after the cutoff; `dropUncollectableObservations` strips observation needs (and each strategy's `OpenInterest` flag) from a consumer on a non-websocket feed source and keeps it loaded with a `Notice`; `feedSealVersionFor` picks seal v2 only when a seal carries observations.
  `freezeMarketSnapshot` takes a separate cutoff (the seal key for the feed, the cycle time in-process) and `freezeObservation` copies only samples at or before it.
  `feedStrategyRequirement.observationNeeds` threads windows through `cycleRequirementsForDue`, `fullCycleRequirements`, `unionFeedRequirements` and `estimateFeedSealBytes`; `attachObservationPayloads` adds them to single and batch payloads and never fails a payload.
  `holdFor` ignores observations by design.
  The recorder (`market_feed_recorder.go`) is fed by non-blocking `offer` calls under `feedMu`; one goroutine writes, rotates, hashes and counts overflow drops, so file I/O never holds `feedMu`.
  `TestOpenInterestObservationPipeline` drives a local socket source through owner, recorder, sealer, Unix socket and client; `scripts/test_observation_replay.sh` adds the Python replay comparison outside the Go suite (run by the `shell-suites` CI job).
- Trade flow (`market_feed_trades.go`, #1638): `feedTradeFlowState` in `marketFeedOwner.tradeFlows` under `feedMu`, keyed like open interest with `Kind=trade_flow`; `applyObservationNeedsLocked` routes by kind. `IngestTradesFrame` / `IngestTradeSubscription` run from `handleSocketMessage` (an undecodable message counts as a malformed trades frame only when trade flows exist); `stopOnSilence` runs for each frame coin before its trades are ingested, so the frame that ends a silence never hides it; `advance` finalizes minutes and also detects silence, called after every frame, by `AdvanceTradeFlows` and by `freezeMarketSnapshot` (recorded as a `tick` only when it emits events). Every recorder event is emitted in state order, so `backtest/trade_flow_replay.py` reproduces it exactly; change both together.
  `TestTradeFlowObservationPipeline` drives owner, recorder, sealer, Unix socket and client through every outcome; `TestTradeFlowCapacityAtMaximumWindow` checks a 26 h four-coin seal and batch payload against `estimateFeedSealBytes` and the caps; `scripts/test_trade_flow_replay.sh` adds the Python replay and tamper refusals (`shell-suites` CI job).
- Volume profiles (`market_feed_profile.go`, #1665): `feedProfileState` hangs off `feedTradeFlowState.profile` under `feedMu`; `ingestTrade`, `advance`, `stopCoverage` and `trim` call its hooks (`addTrade`, `observeNewTrade`, `invalidateMinute`, `finalizeMinute`), and `backtest/volume_profile_replay.py` mirrors them on `trade_flow_replay._Flow`; change both together.
  The canonical artifact hash text (`feedProfileCanonicalText`, `volume_profile_inputs.canonical_text`) and the sealed sessions (`feedProfileSealSessions`, `sealed_sessions`) must stay identical in both languages.
  `TestVolumeProfileObservationPipeline` drives owner, recorder, sealer, Unix socket and client with a hand-calculated day; `TestVolumeProfileCapacityAtLimitsAndEntryOnlyIsolation` checks a 16-coin two-session seal and both payloads at the limits; `scripts/test_volume_profile_replay.sh` adds the Python replay comparison and tamper refusals (`shell-suites` CI job).
- Order-book depth (`market_feed_books.go`, #1635): `feedBookState` in `marketFeedOwner.books` under `feedMu`; `IngestBookFrame`, `IngestMalformedBookEnvelope` and `IngestBookSubscription` run from `handleSocketMessage`; one owner-wide `bookSeq` numbers frames and markers; `tickBooksLocked` runs from the per-session timer (`feedBookTickEvery`) and every freeze. Every recorder record is emitted in state order (markers after their frame, epoch and depth-raise markers before their `book_need`, disconnect markers before the `conn` record), so `backtest/book_replay.py` reproduces outcomes, sequences and markers exactly; change both together.
  `TestL2BookObservationPipeline` drives owner, recorder, sealer, Unix socket and client through every outcome and marker; `TestL2BookRequirementsSurviveEveryUnionAndRefuseConflicts` covers derivation, unions and admission; `TestL2BookCapacityAtTheJointBoundsAndIsolatedOverflow` checks a 16-key maximum seal and the single payload at the bounds; `TestL2BookMixedRecordingKeepsEveryImporter` records open interest, trades and books in one v3 run; `scripts/test_book_replay.sh` adds the Python replay, mixed-run imports and tamper refusals (`shell-suites` CI job).
- Premium samples (`market_feed_premium.go`, #1639): `marketFeedOwner.premiums` keyed like other observations; `record-observations --kinds perp_premium` writes v3 run/segment/recording manifests (every raw `bbo`/`activeAssetCtx` frame with its outcome, each finalized `psample`, each `pinvalidate`, finalizing `ptick` records); `backtest/premium_replay.py` (behind `observation_replay.py import-premium`/`harness-premium`) reruns the state machine from the raw frames and refuses a run whose outcomes or events differ. Change the Go owner and the Python replay together.
  `TestPremiumObservationPipeline` drives owner, recorder, sealer, Unix socket and client through every outcome; `TestPremiumCapacityAtMaximumWindow` checks a 26 h four-coin seal and batch payload; `TestV3RecordingSchemaTypesEachKind` records premium-only, book-only and mixed v3 runs with the real owner and recorder; `TestSealWithBooksAndPremiumsIsV8AndPayloadV4CarriesBoth` seals a book and a premium need together (v8 seal, payload v4 with both sections); `scripts/test_premium_replay.sh` adds the Python replay, decisions, tamper refusals, every importer on those three runs and on the frozen #1639 recording, and kind/contract mismatch refusals (`shell-suites` CI job).
- Macro-event inputs (`market_feed_macro.go`, #1654): `marketFeedOwner.macro` holds prepared immutable artifacts and `macroNeeds` the installed needs, both replaced only in `publishGeneration`; `freezeMacroLocked` copies under `feedMu` with a receipt-sorted index. Record hashes are sha256 of canonical JSON field arrays, identical in Go (`SetEscapeHTML(false)`) and Python; change both readers together. `TestMacroEventFeedPipeline` drives derivation, owner, sealer, Unix socket, client, strict decode, holds and reload refusals; `scripts/test_macro_event_replay.sh` feeds Python-imported artifacts through it and compares websocket, shared and offline decisions (`backtest/macro_event_replay.py`, `shell-suites` CI job).
- The fill resolver is built outside `mu.Lock`; a resolver failure falls back to the modeled fee. Reconcile paths treat unconfirmed SL fills as gaps, never as books.
- `reconcileHyperliquidAccountPositions` sends public trade alerts for reconciliation closes via `sendTradeAlertRows` in the deferred unlock path. `hyperliquidPublicTradeAlertRows` drops hedge-leg rows so a hedge close alerts only its owner DM. A sole-owner reconciled SL close also queues a `ProtectionFillAlert`.
- Filled stops: `applyHyperliquidProtectionSync` keeps `StopLossOID` and `StopLossTriggerPx` on a stop the sync reports filled, so the reconcile attributes the fill as `stop_loss`. `reconcileSoleOwnerOpenStopFill` books a confirmed sole-owner stop fill that leaves the chain open as a `stop_loss` partial close and clears the stop for the next sync. An unconfirmed fill at resync starts an in-memory `hlStopFillWatch` retried for `hlStopFillLookupRetryCycles` (3) cycles; expiry sends one `HL STOP FILL UNCONFIRMED` owner DM, and a restart drops the watch.
- Hyperliquid perps step alerts (`hyperliquid_step_trade_alerts.go`): each strategy step captures a trade-history baseline under `mu.RLock` after `runHyperliquidCheck` and before the paper breach check.
  Every producer binds its summary line to the exact history rows it booked (`bindWindow`/`bindWindowLocked`, and `bindExecuteLocked` for the execute leg, which labels the open row).
  After replay and hedge synchronization, `hlStepTradeAlerts.finish` copies the new rows under `mu.RLock`, drops hedge-leg rows through `hyperliquidPublicTradeAlertRow`, counts each public row once, keeps one summary line per row (a bound label, else a line from the row's side, symbol, quantity and price), and sends them through `sendTradeAlertRows` outside `mu`.
  A stop that fills at placement, a replayed row and a primary unwind after a failed hedge open therefore alert and count even when the execute books nothing.
  A baseline above the history length is an invariant failure that logs an error and selects no rows; there is no tail-count fallback.
  Execute-owned gates (ratchet scale-in ownership, post-trade protection sync, paper post-execute post-take-profit) read `execTrades`, never the step total.
  The per-strategy tail-count consumer `sendTradeAlerts` runs only when no step helper exists (OKX spot and perps through `runOKXStep`, other spot, options, futures). `runOKXStep` returns trades and detail and never sends trade alerts; the OKX branches leave `hlStep` nil, so the tail sends each OKX trade alert once.
  Indices stay valid because `RecordTrade` only appends and every history writer during a step runs on the cycle goroutine.
  The manual case opens the same step window before the protection sync, binds each producer (protection sync, post-take-profit stop, manual trailing stop, close recovery) to its rows, and finishes once after the strategy-type switch through `finishLines`, because the manual case leaves early through `break`; each public row is one alert, one count and one summary line.
  `settleManualCycleClose` queues the close without a count or line, so a queued close reports nothing at queue time.
  `drainPendingManualActions` copies the public rows each applied action appended (`drainedPublicRows`, history index taken before the apply under `mu.Lock`) and returns them in `manualAlert.rows`; the loop sends them through `sendTradeAlertRows` after `mu.Unlock` and keeps them in `pendingDrainReports` until the next iteration that reaches the summary block (the empty-due branch skips it).
  `foldManualDrainTrades` then adds one count and one summary line per row to the total, channel and asset maps, and `summaryChannelActive` lets a channel with drained rows pass the activity gate with no due strategy.
  The rows count as trades, so `ShouldPostSummary` returns true before it reads `summary_frequency` and the channel posts on that pass, as it does for a due strategy that trades.
  A duplicate close, stop edit, stop cancellation, failed action and hedge-leg row add nothing.
  The pending-limit-order consumer keeps the tail count (`manualAlert.trades` only).
  The carry-over is in memory, so a restart drops only the pending summary contribution; delivery is not exactly-once across crashes.

### Pause, regime, ratchet, hedge, Hurst (`pause.go`, `regime*.go`, `post_tp_sl.go`, `trailing_tp_ratchet.go`, `hedge.go`, `hurst_gate.go`)

- `StrategyConfig.Paused` (`"paused"`) runs a full manage-only cycle and hot-reloads at any time, including while open.
- Regime store failure displays `regime=-`; the entry empty-label policy comes from `resolveRegimeGateOnFailure` (`"open"`|`"closed"`). `regime_atr.go` treats the v15 `atr_multiple` as canonical. The regime label is display-only; regime transitions are alerting-only.
- The ratchet sets SL through `trailing_stop_atr_mult`/`trailing_stop_atr_mult_regime`. A same-cycle tier tighten replaces the resting SL and bypasses `TrailingStopMinMovePct`. The open DM shows the ratchet or trail block; it is suppressed on scale-in and on a non-default `regime_atr_window`.
- Hedge legs run through one reconciler (`hedgeTargetDecision` then `runHedgeSync`); the collision matrix between hedge and owner positions is load-bearing.
- Hurst gate: sits on top of the label gate; `resolveHurstGateOnFailure` fails closed flat-only. Size multiplier `clamp(|H-0.5|/0.15, floor, 1.0)`; hysteresis lives in `strategies.hurst_gate_state`. Hot-reloadable while open. Backtest counterpart `backtest/hurst_gate.py`.

### Higher-timeframe filter (`htf_filter.go`, `shared_tools/htf_filter.py`)

- `htf_trend_filter` returns `htf_status`/`htf_reason`. `ok` needs at least 50 close values (the EMA period), every close finite and positive, and a finite EMA and final close; a missing frame, fewer rows, a fetch or calculation exception, or unusable numbers give `unavailable` with the cause (row totals, or the exception). The default map, the EMA and the neutral pass-through are unchanged.
- Every successful check with `--htf-filter` emits one top-level `htf_filter` block `{status, timeframe, trend, reason}`; Hyperliquid emits it from `evaluate_signal_slot`, so single checks and batch slots match. `ok` applies the filter (trend 0 passes the signal). `unavailable` skips `apply_htf_filter`, keeps the open signal for Go to gate, still composes closes, and prints one `HTF filter unavailable for <symbol> <htf>: <reason>` stderr line. `skipped` marks the script's own exclusions (`delta_neutral_funding`; Hyperliquid also `funding_skew`). `not_evaluated` marks a closed-bar decision held before the filter ran, or TopStep's closed-market return. A closed-bar HTF fetch or selection failure keeps the existing decision hold and reports `unavailable`. Error payloads and a disabled filter carry no block.
- Go (`StrategyDecisionFields.HTFFilter`) validates the block in `runSpotCheck`, `finishHyperliquidCheck`, `runTopStepCheck`, `runRobinhoodCheck` and `runOKXCheck` after the error and closed-bar checks: with the filter on, a missing block, a missing field, an unknown status, a trend outside -1..1 or a non-`ok` block without a reason is a script error; with it off, a block is a script error. `not_evaluated` is accepted only with a held closed-bar decision that names its reason, or a TopStep result whose `market_open` is present and `false` with signal 0 and no close (`TopStepResult.MarketOpen` is `*bool`, so an omitted flag proves nothing). Go-created degraded Hyperliquid results skip the check and keep their protection path.
- `htfFilterHoldsSignal` runs beside the closed-bar hold at all six gate sites (Hyperliquid perps step, OKX spot and OKX perps through `runOKXStep`, Robinhood, Binance.US spot, TopStep): `unavailable`, `not_evaluated` or no block zero the signal only when `pausedBlocksSignal` blocks it, log `HTF filter: <signal> signal suppressed`, and never end the strategy cycle.
- Alerts: `observeHTFFilterResult` runs once per consumed result (the batch pre-pass only stores results). Separate in-memory trackers keyed by strategy ID reuse `recordScriptFailureAtThreshold`: `unavailable` advances (3 evaluations; 15 evaluations or 75 minutes when `scriptFailureErrorIsTransient` matches the reason; repeat on a changed reason, every 10th evaluation or the throttle interval); `ok` clears and sends one recovery after an alert; `skipped` clears silently; `not_evaluated`, degraded results and script errors leave it unchanged. Ordinary script success never clears it. Alerts go through `SendToPartitionChannels(partitionFor(sc))` and `SendOwnerDM` with the partition prefix; there is no config field or migration, and a restart starts empty.
- Offline: `backtest/run_backtest.py::_htf_trend_series` raises `HTFCacheError` for an empty or shorter-than-50 cache, and for a cache whose first bar opens more than one HTF interval after the first lower-timeframe bar or whose last bar opens more than one HTF interval before the last lower-timeframe bar (the message names both ranges; single mode exits 1; compare and multi record `HTF FILTER REFUSED` and exit 1), so no filtered result is saved; `shared_scripts/simulate_strategy.py` returns the error.
  Before it refuses a non-empty cache, `_top_up_htf_cache` extends it once and checks again: backward in 500-bar chunks that end at the first cached bar (down to 50 HTF intervals before the first lower-timeframe bar) for a short cache or a leading gap, and forward one page at a time from the last cached bar for a trailing gap.
  Each chunk or page is stored as soon as it arrives and always touches the cached rows, so a stopped or failed top-up keeps its progress, never leaves a hole, and the next run continues from the new edge.
  The `/tuning` simulator gives the top-up a `SIMULATOR_HTF_TOP_UP_BUDGET_S` (15 s) budget, checked before each chunk after the first, so it stays inside the 30 s script limit; the CLI has no budget.
  Cache and fetch output goes to stderr so the simulator's stdout stays one JSON line, and a refusal names the fetch, its outcome (budget stop, fetch error, or no more rows) and says to rerun.
  The `shift(1)` before the forward fill and the lower-timeframe warm-up (bars before the first closed HTF bar read trend 0) are unchanged; the HTF index is aligned to the lower-timeframe index's time zone.
- Tests: `TestHTFFilterIntegration*` (`pyintegration`) runs the real binary `--once` on five paper platforms, the real consumers, the Hyperliquid feed single and batch paths and the alert tracker; `shared_scripts/testdata/htf_offline` supplies controlled ccxt, yfinance and TopStep market-hours inputs, logs yfinance requests (`yahoo_log`) and blocks the network. The Robinhood cases use a `30m` primary (`BTC-USD|30m`) and build the `4h` HTF from hourly `BTC-USD|1h` rows (#1822), so a non-finite HTF there reports the swallowed `no 4h candle frame` reason; `shared_scripts/test_robinhood_timeframes.py` covers the Robinhood mapping, ranges, resampling and paper closed-bar path.

### LLM review, scale-in, manual (`llm_entry_analysis.go`, `llm_review.py`, `scale_in.go`, `manual*.go`)

- The LLM verdict is advisory; it is written only to `trade_diagnostics.llm_verdict`.
- `llm_review.py` raises on any `stop_reason` other than `end_turn` (`refusal` names the `stop_details` category) and parses the judge with strict `json.loads` against `JUDGE_SCHEMA`; it never guesses a verdict from free text.
- Token usage: `llm_review.py` sums `usage.input_tokens`, `usage.output_tokens` and a `calls` count (API replies received, refusals and cut-offs included) over every call of one analysis. It prints the totals as `usage` in its stdout JSON on success and beside `error` on failure (still exit 1), and writes the running total to stderr as `llm_review_usage {...}` after each reply, so a run killed at its timeout still shows the usage of its finished replies. The worker logs one line per job: `verdict <v>; usage calls=N input_tokens=N output_tokens=N`, or `analysis failed: <err>; usage ...` (stdout totals, else the last stderr line, else `usage unavailable`). Usage never decides success: a malformed `usage` value is dropped and the verdict stands.
- Scale-in freezes the stop geometry on `RiskAnchorPrice` instead of the blended `AvgCost`; HL perps plus `manual`; backtested.
- Manual actions run under the kill switch and circuit breaker. `force-close` is live HL perps only.

### Liquidation guard and protection (`hyperliquid_liquidation_guard.go`, `hyperliquid_open_trailing.go`, `hyperliquid_protection.go`)

- `hlLiquidationPx` is a NET per-coin map read via `hlLiquidationPxForSide` against `hlNetSideByCoin`. Healing runs through the trailing `trailingReplacePolicy.liquidationPx` or the static/regime `buildHyperliquidProtectionPlan`, strictly tighter only. `runHyperliquidLiquidationAudit` tightens every owner; `hlLiquidationClampReplace` is tri-state (`protection lost` / re-arm / refuse over-virtual-net). One in-cycle retry on a positively rejected cancel-with-nothing-resting; classification comes from what rests. The off-cycle pass runs at `liquidationAuditIntervalSeconds`, floored at 60s. Preflight: `scripts/check-hl-stop-bankruptcy-bound.sh`. `recordPositionOpen` runs after the deferred-open execute leg. Operator view: § Hyperliquid Liquidation Guard.
- On-chain TP suppression never nils `CloseStrategy`: live tiered-TP checks send `close_owner: on_chain_tp` with `closes` and the position context, Python evaluates no close and echoes the owner, and Go holds a signal whose result lacks the echo. `hlCloseOwnerForCheck` hands the exit to the in-process evaluator (one alert) only when nothing rests or is armed and the tiers are unplaceable. Paper never places on-chain TPs.
- `invert_signal` flips only the open signal, inside the Python composer (`invert_open_signal` on the refs, echoed as `open_signal_inverted`). Go never negates a check result. A same-side close (`CloseFraction > 0` on the position side) is zeroed before the gate chain, so it cannot become a scale-in add. A missing or extra echo holds the signal. `type: manual` is not inverted.
- Moved stops: a stop a take-profit `sl_after` rule moved carries `Position.SLAfterMoved` (set on every venue-confirmed move, unified and non-unified, paper included) and `SLAfterTriggerPx` (the last venue-confirmed trigger, column `sl_after_trigger_px`, captured by `noteMovedStopTrigger` and before every clear by `clearRecordedStopLoss`; it never marks an order as resting).
  `buildHyperliquidProtectionPlan` then sends `StopLossTriggerPx` (the resting trigger, else the preserved one; the label only when strictly tighter; liquidation clamp tightens only) with `PreserveMovedStop` as `--stop-loss-trigger-px`/`--preserve-moved-stop`.
  Python rounds it never looser, or places nothing.
  An unrecoverable trigger strips the stop leg before any cancel, returns `hlStopRearmMovedTriggerLost`, and sends one CRITICAL; there is no label fallback.
  The main-loop fixed-ATR arm skips moved positions so the sync is the only re-placer.
  Startup `backfillMovedStopMarkers` marks legacy non-unified positions whose processed tiers held a rule.
  The `--sync-protection` argv is probed at startup (`syncProtectionProbeArgv`).

### Probes, diagnostics, alerts, commands

- `version_probe.go`/`probe_cmd.go`/`exit_codes.go`: every unique check script runs with `--probe-only` at startup; `probeFailureScriptMissing` detects `"can't open file"`; a failure logs, DMs the owner, and exits 78 (`EX_CONFIG`).
- `trade_diagnostics*.go`: eager insert in `recordClosedPosition`; MFE/MAE computed async outside `mu`.
- `agent_info.go`: read-only dump.
- `logger.go`: `log_level` gate. A new per-cycle or per-check line uses `logger.Debug`/`logDebugf` or an on-change form (`InfoOnChange`, `logOnChangef`, keyed on a stable state value, never on a price or balance); errors, warnings, trades and non-HOLD signals stay unconditional. Event-driven protection sync lines (after a trade, a limit fill, a failed-close re-arm) stay `logger.Info`.
- `failure_alerts.go`/`script_failure_alerts.go`: primary alert at 3 strikes; transient 429/5xx/timeout stays WARN until 15 strikes or 75 minutes.
- `discord_commands.go`/`discord_mutating_commands.go`: `/clear-cash-reconcile`, `/clear-operator-close` (`operator_required_close.go`), `/resolve-entry` (`entry_operator.go`), `/resolve-robinhood-order` (`robinhood_order_recovery.go`) and `/resolve-pair-native` (`discord_pair_native.go`, `pair_native_recovery.go`) mutating, `/closing-strategies` read-only.
- `missing_mark_alerts.go`: throttled DM per `(strategy_id, symbol)`. `hl_reconcile_gap_alerts.go`: alerting only. `portfolio_warning.go`, `circuit_breaker_alert.go`: alert routing.
- `model_only_reconcile.go`: § Model-Only Close Reconciliation. `portfolio.go`: `CashReconcileRequired` (§ Cash Reconcile Latch). `kill_switch_close.go` and `*_close.go`: `type=manual` HL positions join the flatten via `hlKillSwitchAll`.

### Shared wallet, cashflow, limit orders (`shared_wallet*.go`, `cashflow_journal.go`, `kill_switch_limit_orders.go`, `orphan_limit_cancel_alerts.go`, `limit_fill_exposure.go`)

- Shared-wallet drift tolerance is $0.01 over 2 cycles. Pool sizing comes from account equity minus deployed margin; switching a strategy between allocated and pool budgeting needs a flat book and a restart.
- Cashflow journal: HL total-drift is live; OKX and TopStep run in shadow. HL expected equity subtracts the gap between frontend `closedPnl` and fill-price realized PnL. Each fill's gap is stored at ingest (`hl_basis_error`) from per-coin books in the same transaction; an unresolvable same-timestamp chain (by `startPosition` from the held size) or unknown entry books a zero gap. Every journal, including a new one, first backfills exchange fill history up to its fill cursor, one page per cycle. The alarm holds `PENDING` for up to `sharedWalletDriftAlertThreshold` cycles, then logs `OFF` and uses the trade ledger until the backfill finishes. A close whose entry is outside exchange history keeps a zero gap and is counted in a warning. Stored fill amounts stay `closedPnl - fee`.
- Kill-switch limit orders: each row goes cancel → `--limit-status` → delete under a 60s pre-flatten deadline; an unresolved row clears `OnChainConfirmedFlat` and blocks `CanAutoResetWithoutOwner`. Operator view: § Portfolio Kill Switch And Latch Ownership.
- Orphan cancel lane: rows in `cancel_requested` or expired that fail `killSwitchLimitOrderAdoptionBlock`; roster from `killSwitchLimitOrderRoster` plus `collectKillSwitchLimitOrderCandidates`. Severity-gated throttle; `operator_required_since` backs off the poll. `applyLimitExposureOperatorRequired` sets the marker on `unbacked`, leaves it on `unreadable`, clears otherwise; the marker is the sole gate for `manual-clear-limit-row <oid> --flattened`.
- The orphan cancel lane is `cancelOrphanedLimitOrder`.
- Limit fill exposure: `hlLiveExposureReader` polls rows, decides per coin, finalizes; `snapshotNewerThan` is the sole reader; `applyCoinLimitFills` aggregates per coin; `classifyLimitFillLiveExposure` requires same-direction and contained. DM severity is gated by outcome.

### Python side (`shared_scripts/`, `platforms/`, `shared_tools/`, `shared_strategies/`, `backtest/`)

- `check_hyperliquid.py` splits into `build_shared_signal_state` and `evaluate_signal_slot` (on `shared["df"].copy()`); single mode and `--batch-check` both run that pair.
  A shared-state failure raises `SharedSignalStateError` → one `error_scope="shared_state"` sentinel; a slot exception stays in its slot.
  Numeric `indicators` are rounded to six decimals except `eom_`, `mfi_`, `ama_`, `erp_`, `stc_`, `trix_`, `cpr_`, `mir_`, `smi_`, `cce_`, `efp_`, `tsi_` and `klinger_` columns (`FULL_PRECISION_INDICATOR_PREFIXES`), which keep full float precision so a `money_flow_index_reversal`, `ease_of_movement_breakout`, `adaptive_moving_average`, `elder_ray_pullback`, `schaff_trend_cycle`, `trix_momentum`, `camarilla_pivot_reversal`, `mass_index_reversal`, `stochastic_momentum_index`, `calendar_conditioned_entry`, `elder_force_pullback`, `true_strength_index` or `klinger_divergence` entry reproduces from the check output (#1658, #1671, #1641, #1648, #1651, #1659, #1669, #1670, #1663, #1653, #1668, #1667, #1662); text columns in `TEXT_INDICATOR_COLUMNS` go to `indicator_labels`.
  Check-time frames carry a `decision_time` attribute (closed-bar cutoff and boundary, empty when untimed) and `_make_dataframe` records out-of-order received timestamps in `received_order_defect_ms` before sorting; only `schaff_trend_cycle`, `camarilla_pivot_reversal`, `stochastic_momentum_index`, `calendar_conditioned_entry` and `klinger_divergence` read them (`mass_index_reversal`, `elder_force_pullback` and `true_strength_index` read `received_order_defect_ms` only), and historical frames carry neither.
  Reference preflight receives the timeframe: a fixed-interval cadence (`expected_interval_ms`, explicit or default) that disagrees with it is refused before any candle fetch, and a batch whose every slot fails preflight returns per-slot refusals without building shared state (#1669).
- HL adapter caches in `/tmp`, lazy `_ensure_exchange`, sparse indices via `_normalize_spot_meta`. The SDK's `asset_to_sz_decimals` keys by integer asset index; resolve via `name_to_asset(symbol)` (fallback `coin_to_asset`); a direct symbol lookup is a legacy test-mock-only fallback.
- `shared_tools/atr.py` shims `shared_strategies/open/indicators_core.py`; `atr_method` resolves via `resolveATRMethod`. `indicators_core.py` holds ATR/RSI, the per-endpoint `fixed_length_ema` (exactly L values, restarted at each bar's own seed; #1668), plus `hurst_exponent` (DFA, live SSoT) and research-only `hurst_rescaled_range`. Options strategies live in `options/strategies.py`.
- Strategy DSL: config params sit under runtime; `HTFFilter` is not available on options or `delta_neutral_funding`. `no_edge` holds 74 names (`edge_status.go` `noEdgeStrategies` mirrors the Python registry; `scripts/test_go_python_registry_parity.py` checks it with platforms, short names and native close names); admission is `noEdgeAdmissionErrors`, warnings `noEdgeStartupWarnings`. `productionRefusedStrategies` (`production_refusal.go`, mirrored by registry `production_refused=True` and checked by the parity script) refuses a positional, effective open or close-fallback reference in every loader before the market-feed check and in `buildAddStrategyEntry`; Python `admit_strategy_references` refuses the same references before the no-edge gate, in solo and batch checks.
- Backtest files: `backtester.py`, `optimizer.py`, `run_backtest.py`, `backtest_{options,theta,pairs}.py`, `parity_diff.py`. `--config <path> --strategy <id>` reads the single `close_strategy` and applies `user_defaults` by default (`--defaults system` keeps the built-in baseline). `--intrabar-resolution bar_close` is the legacy race resolution. Regime: `--config` threads `allowed_regimes` (the CLI flag is rejected); composite via `regime.windows`; the open name falls back to `args[0]`. `regime_directional_policy` is backtestable behind its flag; scalar `sl_after` and `*_atr_mult_regime` stop dicts are backtestable. Liquidation floor: a sticky equity floor at 0 from the first bust; blown legs report `±LIQUIDATED_METRIC_FLOOR`. `tune_live.py` writes SCHEMA_VERSION=2 `promotion_baseline`.
- Walk-forward warm-up (`optimizer.strategy_warmup_bars`): the fold prefix is the largest integer grid parameter (`max_indicator_lookback`) except for `calendar_conditioned_entry`, whose prefix is `training_bars + refit_bars - 1` (its training span plus the largest distance to the last scheduled refit) maximized over the grid, so the boundary decision that fills on the first scored bar sees a complete table.
  For `elder_force_pullback` the prefix is the largest `required_history_bars` (`max(recursion_length + 2, trend_period + slope_span + 1)`) over the grid, and never less than the largest integer grid parameter (#1668).
  For `klinger_divergence` the prefix is the largest `support_bars` (`replay_bars + left_span + max_separation + right_span + setup_expiry + 2`) over the grid, each row merged over the registry defaults, and never less than the largest integer grid parameter (#1662).
  Names in `RECURSION_HISTORY_STRATEGIES` (`trix_momentum`, `true_strength_index`) take the larger of the largest integer grid parameter and their module's `required_history_bars(recursion_length)` maximized over the grid, so the same boundary decision sees its full recursion window (#1667).
  Names in `UNSEEDED_ENTRY_ONLY_STRATEGIES` (`calendar_conditioned_entry`, `elder_force_pullback`) never get a prefix replay: their -1 opens a short, so a pre-window long would be invented; every fold starts flat (#1653, #1668).
  Every other strategy also starts flat when the scored engine can open a short (`warmup_handoff_prefix`, #1785), whatever its canonical `short_entries` flag says: the optimizer passes no prefix to such an engine, because the #1796 scope has no tested short handoff path; `Backtester.run(warmup_prefix=...)` itself converts and carries either side.
  The scored engine can open a short when its certified `regime_directional_policy` has a `short` or `both` entry, or when it runs the open/close path (a close evaluator, `open_action` or `close_fraction` columns) with a normalized direction other than `long`; an `open_action` column without a policy also counts.
  Each training candidate and the selected test run decide this from their own engine, after the close stacks are built, so a close-stack grid (default `long`) and an explicit `long` replay the prefix, and an omitted direction with a fixed close evaluator does not.
  A run whose engine cannot open a short passes the prefix to `Backtester.run(warmup_prefix=...)` (#1796): the same engine (that candidate's stack, or the selected `best_bt` for the test fold) runs the prefix rows and the scored rows in one bar loop, so closes, take-profit tiers, stops, stop-after moves, trailing high water, time-stop age, regime stamps, entry gates, sizing state and decisions due on the first scored row carry over exactly as in uninterrupted execution.
  At the first scored row the engine records a `warmup_handoff` snapshot (inventory before any synthetic liquidation, which agrees with a prefix-only ledger `interval_end`) and converts capital: with last prefix close P, cash K, quantity Q and scored initial capital C, it scales cash, quantity, initial quantity, scale-in notionals and perps sizing state by C / (K + Q * P), so the remaining fraction and every price-based level stay unchanged.
  The carried trade's scored profit basis is P with no entry fee, while stops and tiers keep the historical average cost; prefix trades, fees, funding, equity and skip counters never enter scored metrics.
  Absolute USD settings keep their configured values: when the factor is not exactly 1, `warmup_handoff.unscaled_usd_settings` lists each one that is set (`perps_sizing.margin_per_trade_usd`, `perps_sizing.pool_available_margin` under `shared_wallet_pool`, `scale_in.add_notional_usd`, `scale_in.max_added_notional_usd`, and `venue_margin.tiers` under `venue_isolated`), so that result is not a pure scaled copy of uninterrupted execution; the list is empty when the factor is 1.
  The handoff refuses non-finite or nonpositive prefix equity, a non-finite last close under an open position, `starting_long`, `record_events` and `execution_spec`; a replay error skips that candidate or test fold under the fold-error policy, never with a substitute seed, and charge-mode unpriced held hours in the prefix (`warmup_handoff.funding_unpriced_held_hours`) reject it through `charge_funding_rejected`.
  Both prefixes are sliced after `rejoin_funding_columns`; there is no replay cache, and equal training metrics pick the smallest (params, close stack) JSON key, so candidate order does not change the pick. `warmup_exit_long_entry` stays for direct callers only.
  Names in `HORIZON_BOUND_TIME_STOP_PARAMS` (`calendar_conditioned_entry` -> `holding_bars`) score each grid combination only on a copy of each close stack whose `time_stop` `max_bars` equals that combination's horizon (`bind_time_stop_horizons`), so a label horizon never meets a different exit horizon; stacks without `time_stop` are unchanged.
- `calendar_conditioned_entry` (#1653, no-edge, futures, both directions, entry-only): at refit boundaries on a `refit_bars x expected_interval_ms` grid anchored at 1970-01-05T00:00:00Z it fits a UTC weekday/hour table (168 cells) from the trailing `training_bars` bars, using only labels `open[e + holding_bars] / open[e] - 1` whose exit open is at or before the refit bar (the simulator's `time_stop(max_bars=holding_bars)` entry and exit opens).
  Each cell averages labels per UTC week, uses a Newey-West error over the weekly series with a conservative effective sample, and qualifies a direction when its cost-adjusted lower bound at Bonferroni `(1 - confidence) / 336` is strictly positive; a closed bar enters when the next bar's cell qualifies in the last scheduled table.
  A decision needs `training_bars + d` clean rows, where `d` (0 to `refit_bars - 1`) is its distance to the last scheduled refit; any defect inside that history, undeclared timing or a stale check frame holds with a `cce_reason`; `calendar_refit_tables` exports the per-refit tables.
  Live and paper closes have no `bars_held`, so `time_stop` never fires there: `calendarConditionedEntryHistoryErrors` (in `validateConfig`, so `LoadConfig`, `LoadConfigReadOnly` and SIGHUP reload) refuses every open or close reference whose `training_bars` (registry default 4368) is at or below the check frame, which is 200 candles, `regimeRequiredOhlcvLimit` when regime is enabled, or a larger `--ohlcv-limit` in `args`, so every scheduled check holds with `insufficient_history`.
  The study `backtest/candidates/calendar_conditioned_entry_1653/` records the proposed versioned table artifact that a future loader would need.
- Close evaluators: `avwap_stop` is a virtual exit only and is absent from `isTieredTPATRCloseName` and `closeStrategiesSuppressedByOnChainProtection`; `atr_stop` (`atr_source` default `entry`) and the `avwap_stop` buffer (`atr_source` default `live`) use `market_ctx["atr"]` for `live` and the position entry ATR for `entry`, with no fallback.
  They do not read `tp_model`, so this rule is the same on every platform, Hyperliquid included.
  The live-ATR tiered evaluators (`tiered_tp_atr_live`, `tiered_tp_atr_live_regime`, `tiered_tp_atr_live_regime_dynamic`) recompute from `market_ctx["atr"]` under `atr_source: live` (entry ATR fallback) only outside the resting-limit model.
  Under `tp_model: resting_limit` (`check_hyperliquid.py`, and the backtester with `platform=hyperliquid`), they use the entry ATR first and read the live ATR only when the position has no entry ATR and `atr_source` is not `entry` (`_resolve_tier_atr`).
  The position context carries `risk_anchor_price` (argv `--position-risk-anchor-price`, batch slot key; every check script parses it, both probe argvs send it).
  Backtest regime gating blocks entries when `bar_regime ∉ allowed_regimes`.

### Notifications and channels

Every send goes through `MultiNotifier`, which fans out to its backends. Channels are `spot`, `options`, `<platform>`, `<platform>-paper`, `<platform>-paper:<id>`. `resolveChannelKey(platform, type, isLive, source)` resolves a paper strategy's key in the order `<platform>-paper:<source>` (only when `source` names a paper source), then `<platform>-paper`; after those, and first for a live strategy, it takes the first backend holding a `<platform>` key, else a `<type>` key. Summaries, leaderboards, and Sharpe groups therefore split by mode only when a paper key is configured. `SendToPartitionChannels(part, msg)` sends a live-partition message to every channel through `SendToAllChannels`. For a paper partition it sends to the channels that partition's own roster resolves to (`partitionChannelValues` with `resolveTradeChannel`, rebuilt on `ReloadConfig`), never by scanning key suffixes, and falls back to `SendToAllChannels` only when that set is empty.

### Build, deploy, and test mechanics

- `scripts/update.sh --restart` is atomic: preflight → `pull --ff-only` (or `--rsync-from <src>`) → `uv sync --no-dev` → build → probe → journald namespace sync (systemd mode) → binary swap (previous kept as `.prev`) → unit install + `daemon-reload` (systemd mode; previous unit kept as `<unit>.prev`) → restart and verify → rollback on timeout, which restores both `.prev` files. `--all --restart` discovers deployments via `discover_deployment_dirs_from_systemd`. To rebuild the current commit, run the same `bash scripts/update.sh --restart`: with nothing to pull, `pull --ff-only` is a no-op and the script still runs `uv sync --no-dev`, builds Go from the committed `HEAD` (uncommitted build-input changes stop it; untracked files are never built), probes, swaps and restarts. Never rebuild Go alone. Config-only reload is `kill -HUP $(pgrep go-trader)` and Python picks the change up next cycle.
- Post-update classification diffs `<running>..HEAD` per § Post-Update Agent Protocol.
- Container images build only from the root `Dockerfile` (§ Docker Container); `scripts/test_container_image.sh` (needs Docker, `GO_TRADER_TEST_IMAGE`, `GO_TRADER_EXPECT_VERSION` and `GO_TRADER_EXPECT_COMMIT`) is their integration suite.
- Test placement: Go `_test.go` beside the file; Python `test_*.py`. Pure helpers to extract from subprocess wrappers include `perpsLiveOrderSize`, `*OrderSkipReason`, `parseXxxCloseOutput`, and the Sharpe computation. `shared_scripts/test_*.py` sits outside pytest `testpaths`; registry and sys.path tests import through `importlib.util.spec_from_file_location`.

### The `@claude` GitHub workflow (`.github/workflows/claude.yml`)

- `classify` plus `review`, `implement` and `fix-pr` callers of `richkuo/rk-skills/.../claude-run.yml@main`. The callers differ in `mode`, `flow` (`implement` only) and `permissions:` (`review` has no `id-token: write`); `fix-pr` also re-checks that the PR author is an owner, member or collaborator, or `claude[bot]`.
- git and gh run on the Claude GitHub App token. Review comments post as `github-actions[bot]`; implement posts as `claude[bot]`. Patch steps key on `RUN_ID`.
- Mode routing: `pull_request_review*` → `review`; issues, non-PR comments, docs-sync, and release → `implement`; otherwise the keyword after `@claude` (untrusted or fork → `review`; trusted → `fix-pr`).
- Comment patching uses `patch_claude_comment.sh` and `compose_claude_comment.py`, staged from `rk-skills` `templates/claude-workflow/scripts/` into `$RUNNER_TEMP` each run. `.github/scripts/test_workflow_logic.py` executes the real `run:` blocks. Concurrency `group: claude-<N>`, `cancel-in-progress: false`. `timeout-minutes: 90`. `issues: types: [opened]` only.
- The CLAUDE.md revision step must `git commit` and `git push origin HEAD`, then diff `HEAD -- CLAUDE.md` against `origin/main` to catch a silent revert.
- Operator lookup for the latest bot review: `gh api repos/richkuo/go-trader/issues/<N>/comments --jq '[.[] | select(.user.login=="claude[bot]" or .user.login=="github-actions[bot]")] | last | .body'`.

---

## Tests

```bash
/opt/homebrew/bin/go -C scheduler test ./...
uv run --no-sync python -m pytest
uv run --no-sync python shared_strategies/open/test_registry_parity.py
```

If the Go cache needs an explicit writable path: `env GOCACHE=/tmp/go-build-cache /opt/homebrew/bin/go -C scheduler test ./...`.

**Python CI and the test budget.** Python CI has two suites: `default` (`uv run pytest` over `testpaths`) and `shared_scripts` (`uv run pytest shared_scripts/`, which sits outside `testpaths`).
- `python-shard` is a matrix that splits each suite by measured duration per test (pytest-split, `--splitting-algorithm least_duration`, durations from `scripts/pytest_durations/<suite>.json`). Each shard runs `-n auto --dist=worksteal --durations=25` and writes a `--report-log` of every test report. The shard count lives only in the matrix rows (`splits`); to change it, edit every row of that suite.
- `python-collect` runs the syntax check, `uv run python3 scripts/check_test_budget.py`, and `scripts/check_pytest_shards.py collect` for each suite, which records the full pytest collection.
- `python` (the old check name) needs both jobs and runs `scripts/check_pytest_shards.py verify` for each suite. It fails and prints the node ids when a test is in the collection but no shard ran it, when a test ran more than once, when a shard ran a test that the collection does not hold, when a test failed, when a shard log for groups 1 to N is missing or did not finish, or when any Python job did not succeed.
- Durations upkeep: the `python` job uploads a `pytest-durations` artifact made from the shard logs. To refresh, run `tmp=$(mktemp -d) && gh run download <green main run id> -n pytest-durations -D "$tmp" && test -s "$tmp/default.json" && test -s "$tmp/shared_scripts.json" && cp "$tmp/default.json" "$tmp/shared_scripts.json" scripts/pytest_durations/` and commit both files. `gh run download` refuses to replace an existing file, so the command downloads into an empty directory and copies only when the artifact holds both files. A stale or missing durations file only makes shards uneven (a test with no recorded duration gets the average); it never drops a test, because `verify` compares against the full collection. The `verify` output says how many collected tests have no recorded duration.
Under pytest-xdist each worker and each check subprocess has its own process memory, so a test never coordinates shared files through worker globals; use `tmp_path`.
The budget command checks two rules against `scripts/test_budget_baseline.json`, which must be an object with exactly the keys `wording_only_tests` and `refusal_grid_tests`, each a list of unique test identifiers:
- `wording_only_tests`: a test whose every assertion only matches message wording. Baseline one only when the wording drives an operator decision.
- `refusal_grid_tests`: a `pytest.mark.parametrize` matrix with at least three no-edge refusal cases. The command parses test sources with `ast` and never imports or runs a test module. A case is a refusal case when it holds one refusal-class string (`explicit live mode`, `missing mode`, `invalid mode input`, `invalid acknowledgement`) and an argument list that the shared admission parser in `shared_tools/strategy_composition.py` refuses with that class; the empty list is a missing-mode case. Test names and mode tokens alone never classify a case.
- Supported matrix forms: a literal list, a module-level name (or alias chain, or a `from <test module> import` name) that resolves to a literal list, and `pytest.param` entries. A case that carries a strategy reference (a dict, any call such as `json.dumps`, or a `--strategy-refs`, `--open-strategy` or `--close-strategies` token) is mixed effective-reference coverage and never counts; admitted and acknowledged cases and unrelated backtest modes match no refusal class.
- The baseline lists only the full Hyperliquid matrix and the four-class wiring matrix in `shared_scripts/test_no_edge_admission.py`. Per-strategy copies are never baselined (see Add Or Change Strategies, no-edge step).
- Exit 1: a detected identifier missing from the baseline (`new:`) or a baseline entry no longer detected (`stale:`), with the rule name and, for `refusal_grid_tests`, a pointer to Add Or Change Strategies; an unreadable or unparsable test source; or an invalid baseline. Exit 0 otherwise.
- `--list` prints `<rule> <identifier>` for both rules (rule order, identifiers sorted) and still validates. `--write-baseline` rewrites both keys, sorted, only after a clean scan; review every identifier it adds before committing.

Test fixtures: `stampEntryATRIfOpened` (`scheduler/main.go`) sets `Position.EntryATR` only when the ATR is positive and not NaN and, when `AvgCost` is positive, no more than 50% of `AvgCost`, and never overwrites a set value. A fixture with a positive `AvgCost` and a larger ATR gets no entry ATR; a fixture with `AvgCost` 0 gets any positive ATR.

Go CI must not depend on a Python runtime, so a test for a subprocess-based live helper extracts the pure parser or decision helper rather than invoking Python. A Go test that runs a shell suite or script which starts Python (the bankruptcy-bound preflight parity, the merge-paper-instance and update-helper suites) carries `//go:build pyintegration` and runs in the `go-python-integration` CI jobs (`go -C scheduler test -tags pyintegration -run <names> ./...`); Go-to-Python registry parity lives in `scripts/test_go_python_registry_parity.py`.

**Basket safety checks.** The `go-basket-safety` job runs untagged `TestBasketPair(KillSwitch|NativeEmergency|PaperEmergency|NativeRecovery|NativeOperator|Reduction)` tests without the race detector. It prints each test duration. Full basket suites remain local.

**Python-backed Go test shards.** The tests run one after another and many use `t.Setenv`, so CI splits them into parallel jobs by measured time. The `go-python-integration` job holds the only copy of the allowlist in its `-run` regex and passes `-skip` for the families that named shard jobs (`go-python-integration-<shard>`) run; today only `TestMergePaperInstance` runs in a named shard. A new allowlisted test runs in `go-python-integration` unless a named shard selects it. To move a slow family into its own shard, add a `go-python-integration-<shard>` job with the same step shape and add the family to the `-skip` regex of the job that ran it before; `TestShellSuiteCIWiring` proves the result.

**Shell suites in CI.** Every `scripts/test_*.sh` has exactly one wiring in `shellSuiteWirings` (`scheduler/update_sh_syntax_test.go`): a `pyintegration` Go wrapper that the `go-python-integration` allowlist selects (`test_merge_paper_instance.sh`, `test_update_helpers.sh`), a step in a shell-suites CI job (the `shell-suites` job runs `test_observation_replay.sh`, `test_trade_flow_replay.sh`, `test_macro_event_replay.sh`, `test_volume_profile_replay.sh`, `test_book_replay.sh`, `test_premium_replay.sh`, `test_ledger_export.sh`, `test_merge_paper_service_fixture.sh`, `test_robinhood_session_service_fixture.sh`, `test_egress_enforcement.sh` and `test_egress_docker.sh`; the `shell-suites-migrate-<n>` jobs run `test_migrate_service_layout_fixture.sh` in scenario shards), or manual-only with a reason (`test_container_image.sh`, which `.github/workflows/container.yml` runs). Green must mean the suite proved its criteria:
- A shell-suites step runs its suite through `scripts/run_ci_shell_suite.sh`, which fails on a non-zero exit, a missing exact success line, any line that starts with `SKIP:`, or any omission text the step forbids. A Go wrapper calls `assertShellSuiteOutput`, which applies the same rules from the map; the wrapper for `test_update_helpers.sh` builds the binary and passes `GO_TRADER_BIN`, so the effective-cadence drift case runs. Each `go-python-integration` shard step fails on any `--- SKIP` line.
- The systemd fixtures run under `sudo env GO_TRADER_BIN=... [FIXTURE_GO=...|FIXTURE_UV=...]` (secure_path does not find the setup-go toolchain). `MERGE_PAPER_SERVICE_FIXTURE_REQUIRE_RUN=1`, `ROBINHOOD_SESSION_SERVICE_FIXTURE_REQUIRE_RUN=1` and `MIGRATE_SERVICE_LAYOUT_FIXTURE_REQUIRE_RUN=1` turn each SKIP into a failure, as `LEDGER_EXPORT_REQUIRE_CAPTURE=1` does for the ledger suite; unset, the suites still SKIP for manual runs. The merge-paper and Robinhood fixtures run one after the other in `shell-suites`.
- The migrate fixture changes host-wide systemd files and state, so it runs only on ephemeral CI runners, never beside another root fixture. It is the one sharded suite (`shardEnv` in its wiring): each `shell-suites-migrate-<n>` job is a separate runner that builds the binary and runs the fixture once with a literal `FIXTURE_SCENARIOS='<list>'` and the marker `OK: migrate-service-layout fixture passed (<list>)`. The union of the shard lists must equal the default `SCENARIOS` list in the script, each scenario in exactly one shard.
- A new default scenario goes into exactly one shard list (list and marker). To rebalance, read each scenario's time from the `==` section timestamps of a recent green run (`gh run view <run> --log`), and add per-shard setup, build and cleanup time; move scenarios between lists or add a `shell-suites-migrate-<n>` job with the same step shape, so each job stays under 10 minutes. Every CI job with an `actions/setup-go` step passes `cache-dependency-path: scheduler/go.sum` (the module is in `scheduler/`, so the default root `go.sum` finds no key).
- Omission checks match each suite's exact omission text, never a generic `NOTE`/`note:` (the tools print benign notes). Every `echo "NOTE: ..."`/`echo "note: ..."` line in a suite must match exactly one forbidden or permitted omission in the map. Permitted omissions in CI: none. The ledger suite runs as the runner user after `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`: with the Ubuntu 24.04 default (1) the capture worker cannot start (`fork/exec /proc/self/exe: permission denied`), and under `sudo` the shared-mount-namespace worker check exits 126 instead of 1. As the runner user, `strace` is installed and the permission-denied refusal runs, so all three ledger omissions are forbidden.
- `TestShellSuiteCIWiring` (the untagged `go` job) fails when a suite has no wiring, when the allowlist does not select its wrapper, when a wrapper lacks the `pyintegration` tag or does not run its script, when a command in the jobs named `shell-suites` or `shell-suites-*` does not run a non-sharded script exactly once (a comment or a `#` on the line does not count), or when a step drops its success line, a forbidden omission, a require flag, or `sudo`.
- For the sharded migrate fixture, `TestShellSuiteCIWiring` parses the default list from the script and fails on a missing, repeated, unknown or empty scenario, a shard without exactly one single-quoted `FIXTURE_SCENARIOS` list, a marker that does not name that list, or any other mention of `FIXTURE_SCENARIOS` in the workflow. It fails closed when it cannot read the workflow (in each shard job, one `-run '...'` argument and at most one `-skip '...'` argument with no `/`, after `set -o pipefail` in the same step, piped to `tee` with the `--- SKIP` gate reading that log; `run:` as a plain value or `|` block; no `continue-on-error`, `if:`, `shell:` or `defaults:` in a shard job or a shell-suites job; no `strategy:`, `matrix:` or `needs:` in a shell-suites job; no top-level `defaults:`; no `GOFLAGS` in the workflow; no `pyintegration` in any other job). `TestUpdateShellScriptSyntax` runs `bash -n` on every `scripts/test_*.sh` and the listed operator scripts.
- `TestShellSuiteCIWiring` also proves the shard partition from the test functions that a `linux` build with the `pyintegration` tag declares: each test the allowlist selects runs in exactly one `go-python-integration` shard job, no shard job runs a test outside the allowlist, and each shard job selects at least one test. A test that only the `pyintegration` build declares must be in the allowlist or in `pyIntegrationCIExemptions` (`scheduler/update_sh_syntax_test.go`) with a written reason and an issue number (none today), unless its name starts with `TestBasket`: Python-backed basket tests run locally and no Python integration CI job selects them. An exemption must name such a test and must not be allowlisted.

**Format and layout checks.** These checks replace CLAUDE.md prose, so keep each one exact:
- The `go` job format step runs `gofmt -l` over every tracked Go file (`git ls-files '*.go'`, so `scripts/fixtures/ledger_fixture.go` too) and fails on any output or parse error. Run `gofmt -w` after Go edits.
- The `docs` job fails unless `git ls-files .github/scripts` is exactly `.github/scripts/test_workflow_logic.py`. Put any other script under `scripts/`.

---
LLM: GPT-6 | high | Harness: Codex
