Skip to content

Commit 6ff65c3

Browse files
authored
Merge pull request #179 from quicknode/claude/perps-haircut
Perpetual futures: Percolator haircut risk model and an insurance fund
2 parents f529467 + acef9f6 commit 6ff65c3

40 files changed

Lines changed: 2685 additions & 405 deletions

‎finance/perpetual-futures/anchor-v1/CHANGELOG.md‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,69 @@ Tested by `test_open_rejects_position_below_initial_margin` (formerly
5454
`test_single_update_moves_average_by_elapsed_fraction` and
5555
`test_one_manipulated_read_after_idle_does_not_move_average`. The default test market
5656
uses a 1,000 basis point initial margin and a 2,000 basis point band;
57-
`test_profit_capped_at_reserved_notional` triples the price, far outside the
57+
`test_profit_runs_uncapped_when_backed` triples the price, far outside the
5858
band, so it now calls `update_price_average` to record the new price, lets a
5959
full window pass, and calls it again before closing.
6060

61+
Replace reserved liquidity with the haircut risk model from
62+
[Percolator](https://github.com/aeyakovenko/percolator): trader collateral is
63+
senior, and trader profit is junior, paid only as far as the pool can back it.
64+
`Pool.reserved_liquidity` is removed, and with it `open_position`'s
65+
`reserved + size <= liquidity` check, which failed with `InsufficientLiquidity`,
66+
and `close_position`'s cap on profit at the position's size. A position opens
67+
whatever the pool's liquidity, and profit has no cap. `close_position` computes
68+
the haircut ratio `h = min(1, (liquidity + insurance_fund) /
69+
max(0, traders' aggregate unrealized profit, closing position's profit))` from
70+
the per-side accumulators, before the closing position leaves them, and pays a
71+
winning position `profit * h / HAIRCUT_PRECISION`, rounded down, with the new
72+
constant at 10^9, so every winner closing at the same moment is paid the same
73+
fraction; a loss settles in full. A winner who closes while open losers still
74+
offset them is paid at most the pool's backing rather than refused, and every
75+
other winner's fraction is unchanged. The profit is paid from `liquidity` first
76+
and from the insurance fund for the rest; `PoolInsolvent` remains as a
77+
defensive check.
78+
`remove_liquidity` caps a withdrawal at `liquidity` rather than `liquidity -
79+
reserved_liquidity`, still failing with `InsufficientLiquidity`, whose message
80+
now says the withdrawal is larger than the pool's liquidity. `shared.rs` has
81+
the new `haircut_ratio` and `apply_haircut`.
82+
83+
Add an insurance fund. `Pool.insurance_fund` is new, and so is
84+
`PoolParameters.insurance_fee_bps`, which `initialize_pool` requires to be below
85+
10,000 or fails with `InvalidParameter`. That fraction of every open and close
86+
fee goes to the fund, rounded down, and the rest to `program_fees`, through the
87+
new `split_fee` and `credit_fee` in `shared.rs`. `liquidate_position` takes a
88+
position's deficit, its loss beyond its collateral, from the fund first and
89+
credits what the fund pays to `liquidity`; the providers bear the rest. The
90+
liquidation fee is still paid only out of the position's remaining equity: the
91+
part the equity cannot cover is forgiven, as in Percolator, and neither the
92+
insurance fund nor `liquidity` pays it. The vault holds `liquidity +
93+
total_collateral + program_fees + insurance_fund`, plus any tokens sent to it
94+
directly.
95+
96+
Add a profit warm-up. `PoolParameters.profit_warmup_slots` and
97+
`Position.entry_slot`, which `open_position` sets to the current slot, are new.
98+
`close_position` refuses to pay a profit before slot `entry_slot +
99+
profit_warmup_slots` with the new `ProfitNotMatured` (6022). A losing position
100+
closes at any time, and liquidation is not delayed.
101+
102+
Tested by `test_open_allowed_without_full_backing`,
103+
`test_profit_runs_uncapped_when_backed`,
104+
`test_haircut_scales_profit_when_pool_stressed`,
105+
`test_insurance_pays_profit_beyond_liquidity`,
106+
`test_winner_offset_by_open_loser_is_paid_not_refused`,
107+
`test_remove_liquidity_capped_at_liquidity`,
108+
`test_profit_blocked_before_maturation`,
109+
`test_profit_realized_after_maturation`, `test_loss_not_gated_by_maturation`,
110+
`test_insurance_fund_funded_by_fees`, `test_insurance_absorbs_bankruptcy_deficit`,
111+
`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` and
112+
`test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee`. They replace
113+
`test_open_rejects_when_pool_cannot_back_it`,
114+
`test_profit_capped_at_reserved_notional` and
115+
`test_remove_liquidity_blocked_by_reserved`. The default test market pays half
116+
of each fee into the insurance fund and has a 10-slot warm-up, so the tests that
117+
close at a profit first let the warm-up pass, and `test_open_long_updates_pool`
118+
checks the fee split.
119+
61120
## 2026-09-30
62121

63122
Remove `set_funding_rate`. The pool's authority could change the funding rate at

‎finance/perpetual-futures/anchor-v1/README.md‎

Lines changed: 54 additions & 23 deletions
Large diffs are not rendered by default.

‎finance/perpetual-futures/anchor-v1/TERMINOLOGY.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,25 @@ Terms used in this example, in the sense they carry here.
2222
- **Maintenance margin**: the minimum equity, as a fraction of notional size,
2323
a position must keep to avoid liquidation.
2424
- **Liquidation**: closing an under-margined position. Permissionless here: any
25-
caller can trigger it and earns the liquidation fee.
25+
caller can trigger it and earns the liquidation fee out of the position's
26+
remaining equity. The part of the fee the equity cannot cover is forgiven.
27+
- **Deficit**: what a liquidated position lost beyond its collateral, when its
28+
equity is below zero. The insurance fund pays it first, and the liquidity
29+
providers bear what the fund cannot.
30+
- **Insurance fund**: the tokens the pool holds, in `insurance_fund`, from
31+
`insurance_fee_bps` of every open and close fee. It pays deficits, pays a
32+
winner's profit once `liquidity` is exhausted, and never pays a fee.
33+
- **Senior / junior**: a trader's collateral is senior, always theirs to
34+
reclaim less their losses. Their profit is junior: paid only as far as the
35+
pool's liquidity and insurance fund can back it.
36+
- **Haircut ratio (`h`)**: the fraction of their profit every winner closing at
37+
a given moment is paid: one while `liquidity + insurance_fund` covers the
38+
profit owed, and that backing divided by the profit owed when it does not.
39+
The profit owed is the larger of traders' aggregate profit and the closing
40+
position's own, so a winner who closes while open losers still offset them
41+
is paid at most the backing, and never refused.
42+
- **Profit warm-up**: the `profit_warmup_slots` a position must stay open before
43+
it can be closed at a profit. A loss is never held back.
2644
- **Funding**: a periodic payment that anchors the pool's risk. The heavier
2745
side of open interest pays funding to the pool over time.
2846
- **Open interest**: the total notional size currently open on a side.

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ pub const FUNDING_PRECISION: i128 = 1_000_000_000;
1515
/// from two running sums instead of iterating every open position.
1616
pub const SIZE_PRECISION: u128 = 1_000_000_000;
1717

18+
/// Fixed-point precision for the haircut ratio `h`, the fraction of their
19+
/// profit every closing winner is paid. `HAIRCUT_PRECISION` is `h = 1` (profit
20+
/// paid in full); a smaller value pays that fraction of it. A winner's profit is
21+
/// multiplied by `h` and divided by this, rounding down, so rounding never pays
22+
/// a winner more than that fraction.
23+
pub const HAIRCUT_PRECISION: u128 = 1_000_000_000;
24+
1825
/// Liquidity-provider shares withheld from the first deposit. The first
1926
/// depositor receives `deposit - MINIMUM_LIQUIDITY` shares rather than the full
2027
/// amount, the same convention Uniswap V2 uses, and both `add_liquidity` and

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub enum PerpError {
3838
#[msg("Fill price is worse than the caller's acceptable price")]
3939
SlippageExceeded,
4040

41-
#[msg("Pool does not have enough free liquidity to satisfy this request")]
41+
#[msg("Withdrawal is larger than the pool's liquidity: part of the shares' value is still in open positions")]
4242
InsufficientLiquidity,
4343

4444
#[msg("Posted collateral does not cover the open fee")]
@@ -67,4 +67,9 @@ pub enum PerpError {
6767

6868
#[msg("Oracle price is too far from the pool's average price: trading pauses until the average catches up")]
6969
PriceOutsideBand,
70+
71+
#[msg(
72+
"Profit cannot be taken yet: the position has not been open for the pool's profit warm-up"
73+
)]
74+
ProfitNotMatured,
7075
}

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/close_position.rs‎

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use anchor_spl::{
77
use crate::constants::{POOL_SEED, POSITION_SEED, VAULT_SEED};
88
use crate::errors::PerpError;
99
use crate::instructions::shared::{
10-
basis_points_of, refresh_price_and_funding_within_band, settle_position,
10+
apply_haircut, basis_points_of, credit_fee, haircut_ratio, position_pnl,
11+
refresh_price_and_funding_within_band, settle_position,
1112
};
1213
use crate::state::{Pool, Position};
1314

@@ -18,15 +19,36 @@ pub fn handle_close_position(
1819
let pool = &mut context.accounts.pool;
1920
let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?;
2021

22+
// The haircut is computed while this position is still in the per-side
23+
// accumulators, so its own profit counts toward the liability and it is
24+
// paid the same fraction as any other winner closing at this price. Its
25+
// own profit is passed too: if open losers offset it in the aggregate,
26+
// the haircut is sized against that profit, so the payout is at most the
27+
// backing and the close is never refused for lack of it.
2128
let position = &context.accounts.position;
29+
let closing_profit = position_pnl(position.side, position.size, position.entry_price, price)?;
30+
let haircut = haircut_ratio(pool, price, closing_profit)?;
31+
2232
let position_size = position.size;
33+
let entry_slot = position.entry_slot;
2334
let settlement = settle_position(pool, position, price)?;
2435
let close_fee = basis_points_of(position_size, pool.close_fee_bps)?;
2536

26-
// Recoverable profit is capped at the reserved amount (the position's
27-
// notional `size`), so the pool can always cover a winner. Losses are not
28-
// capped.
29-
let realized_pnl = settlement.profit_and_loss.min(position_size as i128);
37+
// A profit is paid only once the position has been open for the pool's
38+
// warm-up, and then only the haircut fraction of it. A loss settles in
39+
// full, at any time.
40+
let realized_pnl = if settlement.profit_and_loss > 0 {
41+
let matured_at = entry_slot
42+
.checked_add(pool.profit_warmup_slots)
43+
.ok_or(PerpError::MathOverflow)?;
44+
require!(
45+
Clock::get()?.slot >= matured_at,
46+
PerpError::ProfitNotMatured
47+
);
48+
apply_haircut(settlement.profit_and_loss, haircut)?
49+
} else {
50+
settlement.profit_and_loss
51+
};
3052
let equity = settlement
3153
.equity
3254
.checked_sub(settlement.profit_and_loss)
@@ -44,29 +66,35 @@ pub fn handle_close_position(
4466
let payout: u64 = payout.try_into().map_err(|_| PerpError::MathOverflow)?;
4567
require!(payout >= minimum_payout, PerpError::SlippageExceeded);
4668

47-
// Release the position's reserved liquidity now that it is closing.
48-
pool.reserved_liquidity = pool
49-
.reserved_liquidity
50-
.checked_sub(position_size)
51-
.ok_or(PerpError::MathOverflow)?;
52-
53-
// Liquidity providers are the counterparty: they pay the trader's (capped)
54-
// profit and receive their loss, and collect the funding the trader owed.
69+
// Liquidity providers are the counterparty: they pay the trader's
70+
// haircut profit and receive their loss, and collect the funding the
71+
// trader owed. The part of a profit the haircut withholds stays in
72+
// `liquidity`. A payment larger than `liquidity` takes the rest from the
73+
// insurance fund, which the haircut counted as backing. The haircut keeps
74+
// the profit within both; `PoolInsolvent` remains as a defensive check.
5575
let liquidity_delta = settlement
5676
.funding
5777
.checked_sub(realized_pnl)
5878
.ok_or(PerpError::MathOverflow)?;
5979
let new_liquidity = (pool.liquidity as i128)
6080
.checked_add(liquidity_delta)
6181
.ok_or(PerpError::MathOverflow)?;
62-
require!(new_liquidity >= 0, PerpError::PoolInsolvent);
63-
pool.liquidity = new_liquidity
64-
.try_into()
65-
.map_err(|_| PerpError::MathOverflow)?;
66-
pool.program_fees = pool
67-
.program_fees
68-
.checked_add(close_fee)
69-
.ok_or(PerpError::MathOverflow)?;
82+
if new_liquidity < 0 {
83+
let shortfall: u64 = new_liquidity
84+
.unsigned_abs()
85+
.try_into()
86+
.map_err(|_| PerpError::MathOverflow)?;
87+
pool.insurance_fund = pool
88+
.insurance_fund
89+
.checked_sub(shortfall)
90+
.ok_or(PerpError::PoolInsolvent)?;
91+
pool.liquidity = 0;
92+
} else {
93+
pool.liquidity = new_liquidity
94+
.try_into()
95+
.map_err(|_| PerpError::MathOverflow)?;
96+
}
97+
credit_fee(pool, close_fee)?;
7098

7199
// The pool signs the CPI below with its own seeds.
72100
let pool_seeds: &[&[u8]] = &[

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ pub struct PoolParameters {
4141
/// oracle price and that average at which positions may still open or
4242
/// close and liquidity may still move.
4343
pub max_price_deviation_bps: u16,
44+
45+
/// Fraction of each open and close fee, in basis points, paid into the
46+
/// insurance fund; the rest goes to program fees. Must be below 10_000.
47+
pub insurance_fee_bps: u16,
48+
49+
/// Slots a position must stay open before it can be closed at a profit.
50+
pub profit_warmup_slots: u64,
4451
}
4552

4653
pub fn handle_initialize_pool(
@@ -99,6 +106,12 @@ pub fn handle_initialize_pool(
99106
parameters.max_confidence_bps > 0 && parameters.max_confidence_bps < denominator,
100107
PerpError::InvalidParameter
101108
);
109+
// At 10_000 every fee would go to the insurance fund and none to the
110+
// program.
111+
require!(
112+
parameters.insurance_fee_bps < denominator,
113+
PerpError::InvalidParameter
114+
);
102115
// Zero would refuse every price move, however small. At 100% or more the
103116
// band could never refuse a fall, since the oracle price is always
104117
// positive.
@@ -124,9 +137,9 @@ pub fn handle_initialize_pool(
124137
pool.custody_vault = context.accounts.custody_vault.key();
125138
pool.lp_mint = context.accounts.lp_mint.key();
126139
pool.liquidity = 0;
127-
pool.reserved_liquidity = 0;
128140
pool.total_collateral = 0;
129141
pool.program_fees = 0;
142+
pool.insurance_fund = 0;
130143
pool.long_size = 0;
131144
pool.short_size = 0;
132145
pool.long_size_scaled = 0;
@@ -144,6 +157,8 @@ pub fn handle_initialize_pool(
144157
pool.liquidation_fee_bps = parameters.liquidation_fee_bps;
145158
pool.max_confidence_bps = parameters.max_confidence_bps;
146159
pool.max_price_deviation_bps = parameters.max_price_deviation_bps;
160+
pool.insurance_fee_bps = parameters.insurance_fee_bps;
161+
pool.profit_warmup_slots = parameters.profit_warmup_slots;
147162
pool.bump = context.bumps.pool;
148163

149164
Ok(())

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,9 @@ pub fn handle_liquidate_position(
1717

1818
let position = &context.accounts.position;
1919
let position_size = position.size;
20+
let position_collateral = position.collateral;
2021
let settlement = settle_position(pool, position, price)?;
2122

22-
// Release the position's reserved liquidity now that it is closing.
23-
pool.reserved_liquidity = pool
24-
.reserved_liquidity
25-
.checked_sub(position_size)
26-
.ok_or(PerpError::MathOverflow)?;
27-
2823
// Liquidatable only once equity has fallen to or below the maintenance
2924
// margin. A healthy position can only be closed by its owner.
3025
let maintenance = basis_points_of(position_size, pool.maintenance_margin_bps)?;
@@ -33,24 +28,44 @@ pub fn handle_liquidate_position(
3328
PerpError::PositionHealthy
3429
);
3530

36-
// The liquidator's reward comes out of whatever equity remains, capped so a
37-
// position already past zero equity cannot pay out more than it has.
31+
// The liquidator's reward comes out of whatever equity remains. Whatever
32+
// part of the fee the equity cannot cover is forgiven: neither the
33+
// insurance fund nor the liquidity providers pay it.
3834
let remaining_equity: u64 = settlement
3935
.equity
4036
.max(0)
4137
.try_into()
4238
.map_err(|_| PerpError::MathOverflow)?;
43-
let liquidation_fee = basis_points_of(position.size, pool.liquidation_fee_bps)?;
39+
let liquidation_fee = basis_points_of(position_size, pool.liquidation_fee_bps)?;
4440
let liquidator_payout = liquidation_fee.min(remaining_equity);
4541
let trader_refund = remaining_equity
4642
.checked_sub(liquidator_payout)
4743
.ok_or(PerpError::MathOverflow)?;
4844

45+
// A position whose equity is below zero lost more than its collateral. The
46+
// insurance fund pays that deficit as far as it can, and the liquidity
47+
// providers bear only the rest.
48+
let deficit: u64 = settlement
49+
.equity
50+
.min(0)
51+
.unsigned_abs()
52+
.try_into()
53+
.map_err(|_| PerpError::MathOverflow)?;
54+
let insurance_payment = deficit.min(pool.insurance_fund);
55+
pool.insurance_fund = pool
56+
.insurance_fund
57+
.checked_sub(insurance_payment)
58+
.ok_or(PerpError::MathOverflow)?;
59+
4960
// Everything the trader does not get back stays with the liquidity
5061
// providers. Derived from vault conservation: the pool keeps the position's
51-
// collateral minus whatever is paid out as equity.
52-
let liquidity_delta = (position.collateral as i128)
62+
// collateral minus whatever is paid out as equity, and the insurance
63+
// fund's payment toward the deficit moves, inside the vault, from
64+
// `insurance_fund` to `liquidity`.
65+
let liquidity_delta = (position_collateral as i128)
5366
.checked_sub(remaining_equity as i128)
67+
.ok_or(PerpError::MathOverflow)?
68+
.checked_add(insurance_payment as i128)
5469
.ok_or(PerpError::MathOverflow)?;
5570
let new_liquidity = (pool.liquidity as i128)
5671
.checked_add(liquidity_delta)

‎finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/open_position.rs‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use anchor_spl::{
77
use crate::constants::{BASIS_POINTS_DENOMINATOR, POOL_SEED, POSITION_SEED, VAULT_SEED};
88
use crate::errors::PerpError;
99
use crate::instructions::shared::{
10-
basis_points_of, refresh_price_and_funding_within_band, scale_size,
10+
basis_points_of, credit_fee, refresh_price_and_funding_within_band, scale_size,
1111
};
1212
use crate::state::{Pool, Position, Side};
1313

@@ -57,19 +57,9 @@ pub fn handle_open_position(
5757
PerpError::InitialMarginNotMet
5858
);
5959

60-
// Reserve liquidity to cover this position's maximum recoverable profit
61-
// (its notional `size`). The reserve must be backed by liquidity-provider
62-
// capital, which also caps total open interest at the pool's liquidity.
63-
let new_reserved = pool
64-
.reserved_liquidity
65-
.checked_add(size)
66-
.ok_or(PerpError::MathOverflow)?;
67-
require!(
68-
new_reserved <= pool.liquidity,
69-
PerpError::InsufficientLiquidity
70-
);
71-
pool.reserved_liquidity = new_reserved;
72-
60+
// Nothing is set aside to back this position's profit, and the pool's
61+
// liquidity does not limit its size: `close_position` pays each winner the
62+
// fraction of their profit the pool can back (see `haircut_ratio`).
7363
let size_scaled = scale_size(size, price)?;
7464

7565
// Effects: record the position and the pool's new aggregates before moving
@@ -83,16 +73,14 @@ pub fn handle_open_position(
8373
position.entry_price = price;
8474
position.size_scaled = size_scaled;
8575
position.entry_funding = pool.cumulative_funding;
76+
position.entry_slot = Clock::get()?.slot;
8677
position.bump = context.bumps.position;
8778

8879
pool.total_collateral = pool
8980
.total_collateral
9081
.checked_add(net_collateral)
9182
.ok_or(PerpError::MathOverflow)?;
92-
pool.program_fees = pool
93-
.program_fees
94-
.checked_add(open_fee)
95-
.ok_or(PerpError::MathOverflow)?;
83+
credit_fee(pool, open_fee)?;
9684

9785
match side {
9886
Side::Long => {

0 commit comments

Comments
 (0)