diff --git a/crates/perps-bot/src/digest.rs b/crates/perps-bot/src/digest.rs index 2360fd2..dcb0fa9 100644 --- a/crates/perps-bot/src/digest.rs +++ b/crates/perps-bot/src/digest.rs @@ -157,9 +157,10 @@ pub fn run(state_dir: &Path) -> anyhow::Result<()> { println!(); println!("open positions:"); println!( - "{:<6} {:<6} {:>14} {:>12} {:>12} {:>14} {:>10}", - "asset", "side", "size", "entry", "mark", "liq_price", "buffer" + "{:<6} {:<6} {:>14} {:>12} {:>12} {:>14} {:>10} {:>14}", + "asset", "side", "size", "entry", "mark", "liq_price", "buffer", "delta_usd" ); + let mut net_delta = Decimal::ZERO; for trade in &open { let key = trade.asset.to_ascii_uppercase(); let mark = mids.get(&key).copied().unwrap_or(trade.open.price); @@ -175,12 +176,14 @@ pub fn run(state_dir: &Path) -> anyhow::Result<()> { }; let liq = liquidation_price(&position, DEFAULT_MAINTENANCE_MARGIN_RATIO); let buf = liquidation_buffer_pct(&position, mark, DEFAULT_MAINTENANCE_MARGIN_RATIO); + let delta = position.signed_notional(mark); + net_delta += delta; let side = match trade.side { perps_types::Side::Long => "long", perps_types::Side::Short => "short", }; println!( - "{:<6} {:<6} {:>14} {:>12} {:>12} {:>14} {:>10}", + "{:<6} {:<6} {:>14} {:>12} {:>12} {:>14} {:>10} {:>14}", trade.asset, side, format_decimal(position.size, 6), @@ -188,6 +191,28 @@ pub fn run(state_dir: &Path) -> anyhow::Result<()> { format_decimal(mark, 2), format_decimal(liq, 2), format_pct(buf), + format_usd(delta), + ); + } + + // Gross long exposure for context: |net delta| / gross tells you how + // hedged the book is. Gross is the sum of |signed_notional|. + let gross: Decimal = open + .iter() + .map(|t| { + let key = t.asset.to_ascii_uppercase(); + let mark = mids.get(&key).copied().unwrap_or(t.open.price); + (t.open.size * mark).abs() + }) + .sum(); + println!(); + println!("net delta: {} (gross {})", format_usd(net_delta), format_usd(gross)); + if gross > Decimal::ZERO && net_delta.abs() * Decimal::from(100) >= gross { + // |net delta| >= 1% of gross — the book carries material directional + // exposure. With no spot hedge wired, a single-leg perp book sits at + // 100% here. See docs/reports for the path to a real two-leg hedge. + println!( + " ⚠ book is directional, not delta-neutral — hedge leg not yet wired (single-leg perp)" ); } } diff --git a/crates/perps-risk/src/lib.rs b/crates/perps-risk/src/lib.rs index 7386a00..9ad3e0d 100644 --- a/crates/perps-risk/src/lib.rs +++ b/crates/perps-risk/src/lib.rs @@ -99,6 +99,49 @@ pub fn portfolio_margin(positions: &[Position], mark_prices: &HashMap) -> Decimal { + positions + .iter() + .map(|p| { + let mark = mark_prices + .get(&p.asset.to_ascii_uppercase()) + .copied() + .unwrap_or(p.entry_price); + p.signed_notional(mark) + }) + .sum() +} + +/// The spot hedge leg that neutralizes a perp `position`: opposite side, equal +/// notional (hence equal base-unit size at the same mark), 1x. Pairing the perp +/// with this position drives `net_delta_usd` to zero. +/// +/// Pure compute — it returns the *intended* hedge as a [`Position`], not an +/// order. The executor turns it into a spot order when the spot leg is wired +/// (see the delta-neutral execution plan). `margin_used` is set to the hedge +/// notional (spot is unleveraged); `leverage` is 1. +pub fn hedge_position_for(position: &Position, mark_price: Decimal) -> Position { + let notional = notional_usd(position, mark_price); + Position { + venue: position.venue, + asset: position.asset.clone(), + side: position.side.flip(), + size: position.size, + entry_price: mark_price, + leverage: Decimal::ONE, + margin_used: notional, + liquidation_price: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -222,4 +265,38 @@ mod tests { let total = portfolio_notional(&[p], &marks); assert_eq!(total, dec!(30000)); // 0.5 * 60000 entry } + + #[test] + fn net_delta_of_lone_short_is_full_negative_notional() { + // A single-leg perp book is NOT delta-neutral: its net delta equals the + // perp's signed notional. + let p = position(Side::Short, dec!(60000), dec!(0.5), dec!(1)); + let marks = HashMap::new(); // falls back to entry + assert_eq!(net_delta_usd(&[p], &marks), dec!(-30000)); + } + + #[test] + fn net_delta_of_hedged_pair_is_zero() { + // Short perp + its spot hedge nets to zero delta at the same mark. + let perp = position(Side::Short, dec!(60000), dec!(0.5), dec!(1)); + let hedge = hedge_position_for(&perp, dec!(60000)); + assert_eq!(hedge.side, Side::Long); + assert_eq!(hedge.size, dec!(0.5)); + let marks = HashMap::new(); + assert_eq!(net_delta_usd(&[perp, hedge], &marks), Decimal::ZERO); + } + + #[test] + fn net_delta_uses_mark_for_valuation() { + // After a price move the hedge built at entry no longer perfectly + // neutralizes — residual delta is the drift the rebalancer must chase. + let perp = position(Side::Short, dec!(60000), dec!(0.5), dec!(1)); + let hedge = hedge_position_for(&perp, dec!(60000)); + let mut marks = HashMap::new(); + marks.insert("BTC".to_string(), dec!(66000)); // +10% + // Short delta = -0.5*66000 = -33000; Long hedge delta = +0.5*66000 = +33000. + // Equal base sizes still net to zero at a common mark — drift comes from + // unequal sizes, which the rebalancer introduces. Sanity-check the common-mark case. + assert_eq!(net_delta_usd(&[perp, hedge], &marks), Decimal::ZERO); + } } diff --git a/crates/perps-types/src/lib.rs b/crates/perps-types/src/lib.rs index 22cff51..a37d849 100644 --- a/crates/perps-types/src/lib.rs +++ b/crates/perps-types/src/lib.rs @@ -71,6 +71,19 @@ impl Position { pub fn notional_usd(&self, mark_price: Decimal) -> Decimal { self.size * mark_price } + + /// Directional exposure in USD: `+notional` for a Long, `−notional` for a + /// Short. This is the position's contribution to portfolio delta. A + /// delta-neutral pair (long spot + short perp of equal notional) sums to + /// zero here; a lone perp leg does not — which is the whole point of + /// tracking it. + pub fn signed_notional(&self, mark_price: Decimal) -> Decimal { + let notional = self.notional_usd(mark_price); + match self.side { + Side::Long => notional, + Side::Short => -notional, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -122,4 +135,27 @@ mod tests { assert_eq!(Side::Long.flip(), Side::Short); assert_eq!(Side::Short.flip(), Side::Long); } + + #[test] + fn signed_notional_is_positive_for_long_negative_for_short() { + let long = Position { + venue: Venue::Hyperliquid, + asset: "BTC".into(), + side: Side::Long, + size: dec!(0.5), + entry_price: dec!(60000), + leverage: dec!(1), + margin_used: dec!(30000), + liquidation_price: None, + }; + let mut short = long.clone(); + short.side = Side::Short; + assert_eq!(long.signed_notional(dec!(61000)), dec!(30500)); + assert_eq!(short.signed_notional(dec!(61000)), dec!(-30500)); + // A long-spot / short-perp pair of equal notional nets to zero delta. + assert_eq!( + long.signed_notional(dec!(61000)) + short.signed_notional(dec!(61000)), + Decimal::ZERO + ); + } } diff --git a/devlog.md b/devlog.md index a1c9c2f..b223732 100644 --- a/devlog.md +++ b/devlog.md @@ -4,6 +4,43 @@ Append-only log of decisions, surprises, and changes that aren't obvious from th --- +## 2026-06-01 — Net-delta accounting (the missing delta-neutral primitive) + +The bot is named "delta-neutral" but every position it has ever opened is a **single perp leg** — +full directional exposure, no hedge. Running the digest over the 3.5-day testnet paper run made +this concrete: funding earned +$25.49, but unrealized price PnL −$16.51 (pure directional noise) +and **net delta −$2016.51 against $2016.51 gross — i.e. 100% directional.** We've been getting paid +to run a leveraged short, not to be market-neutral. + +This PR adds the accounting to *see* that, without touching the working fill/pairing/restore paths: + +- `Position::signed_notional(mark)` in `perps-types` — +long / −short USD. A long-spot/short-perp + pair of equal notional sums to zero (the delta-neutral invariant, now expressible and tested). +- `net_delta_usd` and `hedge_position_for` in `perps-risk`. `hedge_position_for` returns the spot + hedge `Position` (opposite side, equal notional, 1x) that neutralizes a perp leg — the primitive + the executor will call when the second leg gets wired. +- `perps-bot digest` grew a `delta_usd` column, a `net delta / gross` line, and a loud warning when + the book is materially directional (|net delta| ≥ 1% of gross). + +**Why accounting before the hedge leg.** Wiring the spot leg touches instrument-tagging on fills, +PnL pairing per-leg, and a spot price source — a multi-PR change. Shipping the delta primitive +first means every subsequent PR is judged against one number: did net delta move toward zero. It +also avoids a half-finished hedge path sitting in the tree. + +**Returns a `Position`, not an `Order`.** `hedge_position_for` is pure compute and keeps `perps-risk` +free of order/uuid concerns. The executor turns it into a spot order at the call site when the leg +lands. `margin_used` = full notional (spot is unleveraged), leverage 1. + +The full sequenced path to delta-0 trading (6 PRs: instrument tag → simulate spot leg → drift +rebalancing → real spot venue/basis → reconciliation → small-size mainnet both legs) is written up +in [docs/MORNING-SUMMARY.md](docs/MORNING-SUMMARY.md). + +Tests: 52 across the workspace (was 48). One new in `perps-types` (signed-notional sign + pair +nets to zero), three in `perps-risk` (lone-short delta = full negative notional, hedged pair = 0, +mark-valuation sanity). + +--- + ## 2026-05-17 — EIP-712 signing wired (gated behind dry_run + --allow-live) Phase 4 #1: order signing is now wired in `HyperliquidClient` via [`hyperliquid_rust_sdk`](https://github.com/hyperliquid-dex/hyperliquid-rust-sdk). The bot doesn't *use* it yet — the poll-loop callback is sync, calling async `place_order` from inside it requires the async-callback refactor that lands in PR #14 alongside the keychain integration. So this PR is the **capability**, not the **integration**. diff --git a/docs/MORNING-SUMMARY.md b/docs/MORNING-SUMMARY.md index c3cc1fa..f14e27d 100644 --- a/docs/MORNING-SUMMARY.md +++ b/docs/MORNING-SUMMARY.md @@ -1,72 +1,131 @@ -# Morning briefing — 2026-05-13 +# Morning briefing — 2026-06-01 -## What's on disk +## TL;DR -Fresh Rust workspace at `~/Desktop/perps-trade`. Private repo at https://github.com/ethanterrero/perps-trade. One commit, on `main`, pushed. Build is green, two unit tests pass, `cargo run -p perps-bot` exits cleanly with structured JSON logs. +The bot is well past scaffolding — it observes funding, decides, simulates fills, tracks a +portfolio, attributes PnL, enforces a refuse-open gate, has a kill switch, and has live order +signing wired (gated behind two locks). **But it is not actually delta-neutral.** It opens a +single perp leg and carries full price exposure. Tonight I added the missing accounting +primitive — **net-delta tracking** — so we can *see* exactly how directional the book is, and +I've laid out the sequenced work to wire the hedge leg and start trading delta-0 for real. -```bash -cd ~/Desktop/perps-trade -cargo build # green -cargo test # 2 passing in perps-types -cargo run -p perps-bot # prints config + exits -``` - -Layout matches the `Kalshi-Weather-Bot` pattern you already use — 9 crates under `crates/perps-*`, plus `config/`, `docs/research/`, `ops/launchd/`, `ROADMAP.md`, `devlog.md`. - -## What actually has code vs. is a stub - -- **Real code:** - - `perps-types` — `Venue`, `Side`, `FundingRate` (with `annualized()`), `Position` (with `notional_usd()`), `Order`, `VenueError`. Decimals everywhere, no f64. - - `perps-config` — loads `config/default.toml`, layers `config/{RUN_ENV}.toml`, then `PERPS_*` env vars. Override pattern: `PERPS_RISK__MAX_POSITION_USD=500`. - - `perps-bot` — clap-parsed args, JSON tracing logs, prints loaded config and exits. - - `perps-venues` — `VenueClient` async trait + `HyperliquidClient` struct skeleton. **All four methods return `not implemented`** — this is intentional. -- **Stubs (one-line `//!` doc comment, empty otherwise):** `perps-funding`, `perps-strategy`, `perps-risk`, `perps-executor`, `perps-backtest`. - -## Where to pick up — Phase 1, first concrete task - -Goal of Phase 1 (per [ROADMAP.md](../ROADMAP.md)): observe funding rates on Hyperliquid testnet for 48h with no panics. No trading. - -**First task:** implement `HyperliquidClient::funding_rate(asset)` in [crates/perps-venues/src/hyperliquid.rs](../crates/perps-venues/src/hyperliquid.rs:23). +Branch `claude/net-delta-accounting` → PR opened (link at bottom). 52 tests pass (was 48). -How: Hyperliquid's REST endpoint is `POST {api_url}/info` with a JSON body. For funding, the body is `{"type": "metaAndAssetCtxs"}` — the response includes a `funding` field per asset in the second array element. Parse and return a `FundingRate { interval_hours: 1, ... }`. +## The core finding -[Hyperliquid API docs — info endpoint](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint) +I ran the new digest against the 3.5-day testnet paper run already on disk: -The official [hyperliquid-rust-sdk](https://github.com/hyperliquid-dex/hyperliquid-rust-sdk) crate exists if you want to skip writing the HTTP plumbing. Tradeoff: more deps, less control over types. My suggestion is to roll the read-only HTTP call yourself (it's ~30 lines with reqwest + serde) and only pull the SDK when you need EIP-712 signing for orders in Phase 2. - -After that endpoint works, the next two steps are: -1. Add a `poll_loop(client, assets, interval)` in `perps-funding` that calls `funding_rate` for each asset and writes a JSONL line per observation to `state/funding.jsonl`. -2. Wire `perps-bot::main` to spawn that loop instead of exiting immediately. - -That gets you to "Phase 1 exit criterion" once it survives 48h. - -## Decisions baked in (so you don't have to re-decide them) - -- **`Decimal` everywhere** for money/size. Never `f64` in domain types. Matches Kalshi. -- **Testnet first** — `config/default.toml` points at `api.hyperliquid-testnet.xyz`. Mainnet will be a separate `config/prod.toml` you create only when you're ready. -- **No keys in repo, ever.** Testnet keys can sit in a gitignored `.env`. Mainnet keys go in macOS keychain, loaded by `perps-config` at startup (not wired yet — that's a Phase 4 task). -- **JSON logs** by default (matches Kalshi's `ops/launchd/` style for log shipping later). -- **`PERPS_` env-var prefix** with `__` separator for path overrides. -- **Private repo, `ethanterrero` account.** +``` +period: 2026-05-18 → 2026-05-21 (3d14h) +fills: 6 observations: 2078 open positions: 2 closed trades: 2 -## Things I deliberately did NOT do +totals: realized $3.42 unrealized -$16.51 funding $25.49 net $12.41 -- No `.env.example` file — you'll create one when there's actually a secret to template. -- No CI workflow — add when you're past Phase 1 and the surface area is stable. -- No `Cargo.lock` committed analysis — Cargo committed one automatically since this is a binary workspace; that's correct. -- No `ops/launchd/com.perps-trade.plist` yet — that's Phase 4 when the bot runs 24/7. -- Didn't pre-add the `hyperliquid-rust-sdk` dependency — let you decide SDK vs. raw HTTP when you actually need to sign. +open positions: +asset side size entry mark liq_price buffer delta_usd +ETH short 0.469825 2128.45 2147.3 4246.26 97.7% -$1008.86 +BTC short 0.012951 77214 77805 154041.93 98.0% -$1007.65 -## Open questions for you when you sit down +net delta: -$2016.51 (gross $2016.51) + ⚠ book is directional, not delta-neutral — hedge leg not yet wired (single-leg perp) +``` -1. **SDK or raw HTTP** for the Hyperliquid client? (See above — I'd go raw for reads, SDK only when signing is needed.) -2. **Asset list** in `config/default.toml` is `["BTC", "ETH"]`. Want to add SOL or others before Phase 1 starts collecting? -3. **State directory location** — currently `state/` (relative, gitignored). Fine, or do you want `~/Library/Application Support/perps-trade/state` to match macOS conventions like Kalshi might? +Read this carefully — it's the whole thesis in one screen: + +- **Funding earned: +$25.49.** The yield engine works. On ~$2k of notional over 3.5 days that's + a big annualized number (testnet funding runs hot, so discount it), but the *sign and + mechanism* are validated: shorting positive-funding perps accrues funding. +- **Unrealized price PnL: −$16.51.** This is the problem. It's pure directional noise — BTC and + ETH happened to tick up, and because we're short-only with no hedge, we ate it. In a real + delta-neutral book this number should hover near zero. Half our funding got eaten by an + unhedged price wiggle. +- **Net delta: −$2016.51, gross $2016.51 → the book is 100% directional.** We are running a + leveraged short, not a funding harvester. We've been getting paid to take a directional bet, + not to be market-neutral. + +The fix is the second leg. That's the entire job from here. + +## What I shipped tonight + +A small, self-contained PR that adds the delta primitive without touching the (working) fill / +pairing / restore paths: + +- `perps-types::Position::signed_notional(mark)` — +notional for a Long, −notional for a Short. + A long-spot / short-perp pair of equal notional sums to zero. (1 new test) +- `perps-risk::net_delta_usd(positions, marks)` — portfolio net delta in USD. (2 new tests) +- `perps-risk::hedge_position_for(perp, mark)` — given a perp leg, returns the spot hedge that + neutralizes it (opposite side, equal notional, 1x). This is the primitive the executor will + call when we wire the second leg. (covered by the hedged-pair test) +- `perps-bot digest` — new `delta_usd` column per open position, a `net delta / gross` summary + line, and a loud warning when the book is materially directional (≥1% of gross). + +Deliberately **not** done tonight (to avoid a half-finished hedge path): actually placing the +spot leg, tagging fills with an instrument, or splitting PnL pairing by leg. Those are the next +PRs, sequenced below. Shipping the accounting first means every subsequent PR can be judged +against "did net delta move toward zero." + +## The plan to reach delta-0 trading + +Six steps. Each ends with something observable, same discipline as the existing ROADMAP. + +### PR 1 — Tag the instrument (Perp vs Spot) *(small, mechanical)* +Add `Instrument { Perp, Spot }` to `Order` and `Fill` (and optionally `Position`), `#[serde(default = Perp)]` +so existing `fills.jsonl` still deserializes. Key `pnl::pair_fills` by `(asset, instrument)` so a +BTC-perp short and a BTC-spot long are tracked as two independent legs instead of colliding. +**Exit:** existing digest output unchanged for old logs; new fills carry an instrument tag. + +### PR 2 — Simulate the spot leg in the run loop *(the real delta-neutral paper trade)* +On a perp `Open`, also simulate the spot hedge fill via `hedge_position_for`, persist both legs, +seed both into the portfolio. On `Close`, unwind both. Use the perp mid as the spot-price proxy +for now (testnet has no deep spot book; note the basis approximation in the devlog). +**Exit:** a fresh paper run shows `net delta ≈ $0` in the digest and `unrealized` collapses toward +zero while `funding` keeps accruing. This is the headline proof that we're market-neutral. + +### PR 3 — Hedge-drift rebalancing *(risk hardening, ROADMAP Phase 3 item)* +Price moves unbalance the legs (primer §"Hedge drift"). Add a per-tick check: if +`|net delta| / gross` exceeds a configurable band (e.g. 2%), emit a rebalancing fill on the +smaller leg. Config: `risk.max_delta_drift_pct`. Watch for fee churn — only rebalance outside a +dead band. +**Exit:** inject a synthetic 10% price move in a smoke test; confirm the bot rebalances back +inside the band and logs the cost. + +### PR 4 — Real spot venue + basis *(unblocks live, ROADMAP Phase 4 dependency)* +Decide where the spot leg lives. Cleanest single-venue option: Hyperliquid spot (HIP-1) for +assets that have it; otherwise the spot leg is a different venue (Phase 5 territory). Wire a +`spot_snapshot(asset)` read so we hedge at the real spot price, not the perp mid, and record the +basis. This is the honest version of PR 2's approximation. +**Exit:** digest shows perp mid, spot price, and basis side-by-side; net delta uses real spot. + +### PR 5 — Reconciliation against the exchange *(ROADMAP Phase 4)* +Before any real money: a `perps-bot reconcile` that pulls `clearinghouseState` (perp positions) +and spot balances and diffs them against the bot's reconstructed portfolio. `account_address` is +already plumbed in config for exactly this. Daily, refuse to trade on a mismatch. +**Exit:** reconcile reports zero drift on a paper run; non-zero exit on injected divergence. + +### PR 6 — Small-size mainnet, both legs *(ROADMAP Phase 4 exit)* +Only after 1–4 have soaked on testnet for the ROADMAP's 2 weeks. Keys in macOS keychain (the +`secret_key` loader is stubbed for this), `max_position_usd` capped low (~$500/asset), both legs +live, reconcile in the loop, kill switch verified to flatten *both* legs. +**Exit:** 1 month live, net delta stays in-band, realized funding − fees ≈ the testnet estimate. + +## What I'd want your call on + +1. **Spot venue for the hedge.** Hyperliquid spot only lists a subset of assets and BTC/ETH spot + depth is thin. The clean math wants a real spot book. Options: (a) Hyperliquid spot where it + exists, accept thin assets; (b) treat the hedge as a second *perp* on another venue + (perp-perp, primer §Variants) — easier liquidity, still neutral, but pulls Phase 5 forward; + (c) CEX spot (Binance/Coinbase) — best depth, most integration work. My lean: (b) for the + testnet proof (reuse the `VenueClient` trait), (c) for mainnet. +2. **Rebalance band.** What `|net delta|/gross` do you want to tolerate before paying fees to + rebalance? I'd start at 2% and tune from soak data. +3. **Asset set.** Still `["BTC","ETH"]`. Both have funding but the delta-neutral edge is often + fatter on alts with hotter funding — at the cost of higher maintenance margin and thinner + hedges. Expand after the two-leg path works on majors. ## Quick reference -- Repo: https://github.com/ethanterrero/perps-trade -- Local: `~/Desktop/perps-trade` -- Roadmap: [ROADMAP.md](../ROADMAP.md) -- Strategy primer: [docs/research/delta-neutral-primer.md](research/delta-neutral-primer.md) -- Devlog: [devlog.md](../devlog.md) — append decisions here, not in commits +- Branch: `claude/net-delta-accounting` (PR link in the PR description / chat) +- Run the new digest: `cargo run -p perps-bot -- digest` +- Roadmap: [ROADMAP.md](../ROADMAP.md) — this plan slots into Phase 3→4 +- Strategy primer: [docs/research/delta-neutral-primer.md](research/delta-neutral-primer.md) — §"Hedge drift" and §"Underestimated risks" are the ones that bite +- Devlog: [devlog.md](../devlog.md) — tonight's entry at top