Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions crates/perps-bot/src/digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -175,19 +176,43 @@ 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),
format_decimal(position.entry_price, 2),
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)"
);
}
}
Expand Down
77 changes: 77 additions & 0 deletions crates/perps-risk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,49 @@ pub fn portfolio_margin(positions: &[Position], mark_prices: &HashMap<String, De
.sum()
}

/// Net directional exposure across all positions, in USD. Sum of each
/// position's signed notional (+ long, − short), valued at the supplied marks
/// with a fallback to `entry_price` for any asset missing from `mark_prices`.
///
/// This is the headline number for a delta-neutral book: it should sit near
/// zero. A single-leg perp book (no spot hedge) reports its full perp notional
/// here — i.e. the strategy is currently *directional*, not delta-neutral, and
/// this surfaces exactly how far off zero we are.
pub fn net_delta_usd(positions: &[Position], mark_prices: &HashMap<String, Decimal>) -> 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::*;
Expand Down Expand Up @@ -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);
}
}
36 changes: 36 additions & 0 deletions crates/perps-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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
);
}
}
37 changes: 37 additions & 0 deletions devlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
Loading
Loading