Mc/health max swap with serum effects (#333)

* rearrange, log before expect

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* rearrange, log before expect

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fix test

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* health max swap with serum effects

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* max swap: Fix with serum reserved amounts

* port rust code to ts

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* port tests

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fix method call

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>
Co-authored-by: Christian Kamm <mail@ckamm.de>
This commit is contained in:
microwavedcola1 2022-12-14 09:21:45 +01:00 committed by GitHub
parent f4942fd674
commit 51cded4965
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 241 additions and 74 deletions

View File

@ -19,7 +19,7 @@
"build": "npm run build:esm; npm run build:cjs",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
"test": "ts-mocha ts/client/**/*.spec.ts --timeout 10000",
"test": "ts-mocha ts/client/**/*.spec.ts --timeout 15000",
"clean": "rm -rf dist",
"example1-user": "ts-node ts/client/src/scripts/example1-user.ts",
"example1-admin": "ts-node ts/client/src/scripts/example1-admin.ts",

View File

@ -15,7 +15,7 @@ use super::*;
const ONE_NATIVE_USDC_IN_USD: I80F48 = I80F48!(0.000001);
/// Information about prices for a bank or perp market.
#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct Prices {
/// The current oracle price
pub oracle: I80F48, // native/native
@ -87,7 +87,7 @@ pub fn compute_health(
Ok(new_health_cache(account, retriever)?.health(health_type))
}
#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct TokenInfo {
pub token_index: TokenIndex,
pub maint_asset_weight: I80F48,
@ -129,7 +129,7 @@ impl TokenInfo {
}
}
#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct Serum3Info {
// reserved amounts as stored on the open orders
pub reserved_base: I80F48,
@ -208,7 +208,7 @@ struct Serum3Reserved {
all_reserved_as_quote: I80F48,
}
#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct PerpInfo {
pub perp_market_index: PerpMarketIndex,
pub maint_asset_weight: I80F48,
@ -313,7 +313,7 @@ impl PerpInfo {
}
}
#[derive(Clone, AnchorDeserialize, AnchorSerialize)]
#[derive(Clone, AnchorDeserialize, AnchorSerialize, Debug)]
pub struct HealthCache {
pub(crate) token_infos: Vec<TokenInfo>,
pub(crate) serum3_infos: Vec<Serum3Info>,

View File

@ -206,18 +206,36 @@ impl HealthCache {
// - source_liab_weight * source_liab_price * a
// + target_asset_weight * target_asset_price * price * a = 0.
// where a is the source token native amount.
// Note that this is just an estimate. Swapping can increase the amount that serum3
// reserved contributions offset, moving the actual zero point further to the right.
if point1_health <= 0 {
return Ok(I80F48::ZERO);
}
let zero_health_amount = point1_amount - point1_health / final_health_slope;
binary_search(
point1_amount,
point1_ratio,
zero_health_amount,
let zero_health_estimate = point1_amount - point1_health / final_health_slope;
let right_bound = scan_right_until_less_than(
zero_health_estimate,
min_ratio,
I80F48::from_num(0.1),
health_ratio_after_swap,
)?
)?;
if right_bound == zero_health_estimate {
binary_search(
point1_amount,
point1_ratio,
right_bound,
min_ratio,
I80F48::from_num(0.1),
health_ratio_after_swap,
)?
} else {
binary_search(
zero_health_estimate,
health_ratio_after_swap(zero_health_estimate)?,
right_bound,
min_ratio,
I80F48::from_num(0.1),
health_ratio_after_swap,
)?
}
} else if point0_ratio >= min_ratio {
// Must be between point0_amount and point1_amount.
binary_search(
@ -373,6 +391,25 @@ impl HealthCache {
}
}
fn scan_right_until_less_than(
start: I80F48,
target: I80F48,
fun: impl Fn(I80F48) -> Result<I80F48>,
) -> Result<I80F48> {
let max_iterations = 20;
let mut current = start;
for _ in 0..max_iterations {
let value = fun(current)?;
if value <= target {
return Ok(current);
}
current = current.max(I80F48::ONE) * I80F48::from(2);
}
Err(error_msg!(
"could not find amount that lead to health ratio <= 0"
))
}
fn binary_search(
mut left: I80F48,
left_value: I80F48,
@ -536,12 +573,11 @@ mod tests {
.map(|c| c.health_ratio(HealthType::Init).to_num::<f64>())
.unwrap_or(f64::MIN)
};
// With the binary search error, we can guarantee just +-1
(
source_amount.to_num(),
ratio_for_amount(source_amount),
ratio_for_amount(source_amount.saturating_sub(I80F48::ONE)),
ratio_for_amount(source_amount.saturating_add(I80F48::ONE)),
ratio_for_amount(source_amount - I80F48::ONE),
ratio_for_amount(source_amount + I80F48::ONE),
)
};
let check_max_swap_result = |c: &HealthCache,
@ -556,8 +592,9 @@ mod tests {
"checking {source} to {target} for price_factor: {price_factor}, target ratio {ratio}: actual ratios: {minus_ratio}/{actual_ratio}/{plus_ratio}, amount: {source_amount}",
);
assert!(actual_ratio >= ratio);
assert!(minus_ratio < ratio || actual_ratio < minus_ratio);
assert!(plus_ratio < ratio);
// either we're within tolerance of the target, or swapping 1 more would
// bring us below the target
assert!(actual_ratio < ratio + 1.0 || plus_ratio < ratio);
};
{
@ -654,6 +691,34 @@ mod tests {
// (tracking happens without decimals)
assert!(find_max_swap_actual(&health_cache, 0, 1, 1.0, 1.0, banks).0 < 51.0);
}
{
// check with serum reserved
println!("test 6");
let mut health_cache = health_cache.clone();
health_cache.serum3_infos = vec![Serum3Info {
base_index: 1,
quote_index: 0,
market_index: 0,
reserved_base: I80F48::from(30 / 3),
reserved_quote: I80F48::from(30 / 2),
}];
adjust_by_usdc(&mut health_cache, 0, -20.0);
adjust_by_usdc(&mut health_cache, 1, -40.0);
adjust_by_usdc(&mut health_cache, 2, 120.0);
for price_factor in [0.9, 1.1] {
for target in 1..100 {
let target = target as f64;
check_max_swap_result(&health_cache, 0, 1, target, price_factor, banks);
check_max_swap_result(&health_cache, 1, 0, target, price_factor, banks);
check_max_swap_result(&health_cache, 0, 2, target, price_factor, banks);
check_max_swap_result(&health_cache, 1, 2, target, price_factor, banks);
check_max_swap_result(&health_cache, 2, 0, target, price_factor, banks);
check_max_swap_result(&health_cache, 2, 1, target, price_factor, banks);
}
}
}
}
#[test]

View File

@ -14,6 +14,7 @@ function mockBankAndOracle(
maintWeight: number,
initWeight: number,
price: number,
stablePrice: number,
): BankForHealth {
return {
tokenIndex,
@ -22,7 +23,7 @@ function mockBankAndOracle(
maintLiabWeight: I80F48.fromNumber(1 + maintWeight),
initLiabWeight: I80F48.fromNumber(1 + initWeight),
price: I80F48.fromNumber(price),
stablePriceModel: { stablePrice: price } as StablePriceModel,
stablePriceModel: { stablePrice: stablePrice } as StablePriceModel,
scaledInitAssetWeight: () => I80F48.fromNumber(1 - initWeight),
scaledInitLiabWeight: () => I80F48.fromNumber(1 + initWeight),
};
@ -57,12 +58,14 @@ describe('Health Cache', () => {
0.1,
0.2,
1,
1,
);
const targetBank: BankForHealth = mockBankAndOracle(
4 as TokenIndex,
0.3,
0.5,
5,
5,
);
const ti1 = TokenInfo.fromBank(sourceBank, I80F48.fromNumber(100));
@ -146,18 +149,21 @@ describe('Health Cache', () => {
0.1,
0.2,
1,
1,
);
const bank2: BankForHealth = mockBankAndOracle(
4 as TokenIndex,
0.3,
0.5,
5,
5,
);
const bank3: BankForHealth = mockBankAndOracle(
5 as TokenIndex,
0.3,
0.5,
10,
10,
);
const ti1 = TokenInfo.fromBank(bank1, I80F48.fromNumber(fixture.token1));
@ -390,9 +396,9 @@ describe('Health Cache', () => {
});
it('test_max_swap', (done) => {
const b0 = mockBankAndOracle(0 as TokenIndex, 0.1, 0.1, 2);
const b1 = mockBankAndOracle(1 as TokenIndex, 0.2, 0.2, 3);
const b2 = mockBankAndOracle(2 as TokenIndex, 0.3, 0.3, 4);
const b0 = mockBankAndOracle(0 as TokenIndex, 0.1, 0.1, 2, 2);
const b1 = mockBankAndOracle(1 as TokenIndex, 0.2, 0.2, 3, 3);
const b2 = mockBankAndOracle(2 as TokenIndex, 0.3, 0.3, 4, 4);
const banks = [b0, b1, b2];
const hc = new HealthCache(
[
@ -475,13 +481,13 @@ describe('Health Cache', () => {
for (const priceFactor of [0.1, 0.9, 1.1]) {
for (const target of _.range(1, 100, 1)) {
// checkMaxSwapResult(
// clonedHc,
// 0 as TokenIndex,
// 1 as TokenIndex,
// target,
// priceFactor,
// );
checkMaxSwapResult(
clonedHc,
0 as TokenIndex,
1 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
1 as TokenIndex,
@ -489,13 +495,13 @@ describe('Health Cache', () => {
target,
priceFactor,
);
// checkMaxSwapResult(
// clonedHc,
// 0 as TokenIndex,
// 2 as TokenIndex,
// target,
// priceFactor,
// );
checkMaxSwapResult(
clonedHc,
0 as TokenIndex,
2 as TokenIndex,
target,
priceFactor,
);
}
}
@ -623,12 +629,90 @@ describe('Health Cache', () => {
checkMaxSwapResult(clonedHc, 0 as TokenIndex, 1 as TokenIndex, 4, 1);
}
// TODO test 5
{
console.log(' - test 6');
const clonedHc = _.cloneDeep(hc);
clonedHc.serum3Infos = [
new Serum3Info(
I80F48.fromNumber(30 / 3),
I80F48.fromNumber(30 / 2),
1,
0,
0 as MarketIndex,
),
];
// adjust by usdc
clonedHc.tokenInfos[0].balanceNative.iadd(
I80F48.fromNumber(-20).div(clonedHc.tokenInfos[0].prices.oracle),
);
clonedHc.tokenInfos[1].balanceNative.iadd(
I80F48.fromNumber(-40).div(clonedHc.tokenInfos[1].prices.oracle),
);
clonedHc.tokenInfos[2].balanceNative.iadd(
I80F48.fromNumber(120).div(clonedHc.tokenInfos[2].prices.oracle),
);
for (const priceFactor of [
// 0.9,
1.1,
]) {
for (const target of _.range(1, 100, 1)) {
checkMaxSwapResult(
clonedHc,
0 as TokenIndex,
1 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
1 as TokenIndex,
0 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
0 as TokenIndex,
2 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
1 as TokenIndex,
2 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
2 as TokenIndex,
0 as TokenIndex,
target,
priceFactor,
);
checkMaxSwapResult(
clonedHc,
2 as TokenIndex,
1 as TokenIndex,
target,
priceFactor,
);
}
}
}
done();
});
it('test_max_perp', (done) => {
const baseLotSize = 100;
const b0 = mockBankAndOracle(0 as TokenIndex, 0.0, 0.0, 1);
const b0 = mockBankAndOracle(0 as TokenIndex, 0.0, 0.0, 1, 1);
const p0 = mockPerpMarket(0, 0.3, 0.3, baseLotSize, 2);
const hc = new HealthCache(
[TokenInfo.fromBank(b0, I80F48.fromNumber(0))],

View File

@ -548,26 +548,43 @@ export class HealthCache {
);
}
private static scanRightUntilLessThan(
start: I80F48,
target: I80F48,
fun: (amount: I80F48) => I80F48,
): I80F48 {
const maxIterations = 20;
let current = start;
for (const key of Array(maxIterations).fill(0).keys()) {
const value = fun(current);
if (value.lt(target)) {
return current;
}
current = current.max(ONE_I80F48()).mul(I80F48.fromNumber(2));
}
throw new Error('Could not find amount that led to health ratio <=0');
}
private static binaryApproximationSearch(
left: I80F48,
leftRatio: I80F48,
leftValue: I80F48,
right: I80F48,
rightRatio: I80F48,
targetRatio: I80F48,
targetValue: I80F48,
minStep: I80F48,
healthRatioAfterActionFn: (I80F48) => I80F48,
fun: (I80F48) => I80F48,
): I80F48 {
const maxIterations = 20;
const targetError = I80F48.fromNumber(0.1);
const rightValue = fun(right);
if (
(leftRatio.sub(targetRatio).isPos() &&
rightRatio.sub(targetRatio).isPos()) ||
(leftRatio.sub(targetRatio).isNeg() &&
rightRatio.sub(targetRatio).isNeg())
(leftValue.sub(targetValue).isPos() &&
rightValue.sub(targetValue).isPos()) ||
(leftValue.sub(targetValue).isNeg() &&
rightValue.sub(targetValue).isNeg())
) {
throw new Error(
`Internal error: left ${leftRatio.toNumber()} and right ${rightRatio.toNumber()} don't contain the target value ${targetRatio.toNumber()}, likely reason is the zeroAmount not been tight enough!`,
`Internal error: left ${leftValue.toNumber()} and right ${rightValue.toNumber()} don't contain the target value ${targetValue.toNumber()}!`,
);
}
@ -578,25 +595,16 @@ export class HealthCache {
return left;
}
newAmount = left.add(right).mul(I80F48.fromNumber(0.5));
const newAmountRatio = healthRatioAfterActionFn(newAmount);
const error = newAmountRatio.sub(targetRatio);
const newAmountRatio = fun(newAmount);
const error = newAmountRatio.sub(targetValue);
if (error.isPos() && error.lt(targetError)) {
return newAmount;
}
if (newAmountRatio.gt(targetRatio) != rightRatio.gt(targetRatio)) {
if (newAmountRatio.gt(targetValue) != rightValue.gt(targetValue)) {
left = newAmount;
leftRatio = newAmountRatio;
} else {
right = newAmount;
rightRatio = newAmountRatio;
}
// console.log(
// ` -- ${left.toNumber().toFixed(3)} (${leftRatio
// .toNumber()
// .toFixed(3)}) ${right.toNumber().toFixed(3)} (${rightRatio
// .toNumber()
// .toFixed(3)})`,
// );
}
console.error(
@ -726,31 +734,45 @@ export class HealthCache {
// search to the right of point1Amount: but how far?
// At point1, source.balance < 0 and target.balance > 0, so use a simple estimation for
// zero health: health - source_liab_weight * a + target_asset_weight * a * priceFactor = 0.
// where a is the source token native amount.
// Note that this is just an estimate. Swapping can increase the amount that serum3
// reserved contributions offset, moving the actual zero point further to the right.
if (point1Health.lte(ZERO_I80F48())) {
return ZERO_I80F48();
}
const zeroHealthAmount = point1Amount.sub(
point1Health.div(finalHealthSlope),
const zeroHealthEstimate = point1Amount.sub(
point1Health.sub(finalHealthSlope),
);
const zeroHealthRatio = healthRatioAfterSwap(zeroHealthAmount);
const zeroHealth = healthAfterSwap(zeroHealthAmount);
amount = HealthCache.binaryApproximationSearch(
point1Amount,
point1Ratio,
zeroHealthAmount,
zeroHealthRatio,
const rightBound = HealthCache.scanRightUntilLessThan(
zeroHealthEstimate,
minRatio,
ZERO_I80F48(),
healthRatioAfterSwap,
);
if (rightBound.eq(zeroHealthEstimate)) {
amount = HealthCache.binaryApproximationSearch(
point1Amount,
point1Ratio,
rightBound,
minRatio,
ZERO_I80F48(),
healthRatioAfterSwap,
);
} else {
amount = HealthCache.binaryApproximationSearch(
zeroHealthEstimate,
healthRatioAfterSwap(zeroHealthEstimate),
rightBound,
minRatio,
ZERO_I80F48(),
healthRatioAfterSwap,
);
}
} else if (point0Ratio.gte(minRatio)) {
// Must be between point0Amount and point1Amount.
amount = HealthCache.binaryApproximationSearch(
point0Amount,
point0Ratio,
point1Amount,
point1Ratio,
minRatio,
ZERO_I80F48(),
healthRatioAfterSwap,
@ -761,7 +783,6 @@ export class HealthCache {
ZERO_I80F48(),
initialRatio,
point0Amount,
point0Ratio,
minRatio,
ZERO_I80F48(),
healthRatioAfterSwap,
@ -880,7 +901,6 @@ export class HealthCache {
initialAmount,
initialRatio,
zeroAmount,
zeroAmountRatio,
minRatio,
ONE_I80F48(),
healthRatioAfterPlacingOrder,
@ -994,7 +1014,6 @@ export class HealthCache {
case1Start,
case1StartRatio,
zeroHealthAmount,
zeroHealthRatio,
minRatio,
ONE_I80F48(),
healthRatioAfterTradeTrunc,
@ -1005,7 +1024,6 @@ export class HealthCache {
ZERO_I80F48(),
initialRatio,
case1Start,
case1StartRatio,
minRatio,
ONE_I80F48(),
healthRatioAfterTradeTrunc,