Repository: ccxt/ccxt
Pull request: feat(hyperliquid): update precision
- Applies the significant_digits rule to the market object price precision
- Prepares for the next hyperliquid network upgrade (see commented lines)
- Fixes #23516

Problem statement:
Hyperliquid decimal_to_precision not working properly
### Operating System

Linux, Windows

### Programming Languages

Python

### CCXT Version

4.3.87

### Description

Hello, 

I think decimal_to_precision is not working properly with hyperliquid.

### Code

```
e = ccxt.hyperliquid()
await e.load_markets()
print(e.precisionMode, e.markets['PURR/USDC']['precision']['price'])
print(e.decimal_to_precision(0.18119111, rounding_mode=1, precision=e.markets['PURR/USDC']['precision']['price'], counting_mode=e.precisionMode))
await e.close()
```

```
4 5
0
```

I would expect the second line to be 0.18119 

Perhaps the Precisionmode for this exchange is not correct. It's set to `TICK_SIZE`, if I force it to `DECIMAL_PLACES` it seems to work.

Thanks

Hints from issue discussion:
Hi @gaardiolor

`decimalToPrecision` is a unified method in the exchange, but you'll need to set the precision mode manually when using this function. I suggest using `amountToPrecision` or `priceToPrecision` for amount/price precision, as we've already used these in other functions.
Yeah, I'm passing it as a parameter, but freqtrade (which uses ccxt) is getting the precision mode from the ccxt hyperliquid.py . I can't change that easily.. and ccxt seems the place to get it instead of setting it manually, because it can be different (and may change) for each exchange.

Don't you agree that it's set wrong there? The precision returned by ccxt isn't the tick size, so the precisionMode shouldn't be set to `TICK_SIZE` either, but to `DECIMAL_PLACES` . 

https://github.com/ccxt/ccxt/blob/089d967332857af438c1ca1cb3f22b90128281f1/js/src/hyperliquid.js#L195

Just noticed that the amount precision does seem to be `TICK_SIZE` .. so it seems the precisionMode is different for price and amount, which further complicates things. If possible I think it would be better if both can be set to `DECIMAL_PLACES`.
I see here that the price precision is 'hardcoded' to 5:
https://github.com/ccxt/ccxt/blob/089d967332857af438c1ca1cb3f22b90128281f1/js/src/hyperliquid.js#L604

https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size states that 'Prices can have up to 5 significant figures'. So maybe my assumption that 5 reflected `DECIMAL_PLACES` was incorrect, it seems to be `SIGNIFICANT_DIGITS`.

According to the hyperliquid documentation:
- The amount `DECIMAL_PLACES` seems to be the `szDecimals` returned by the hyperliquid API: "Sizes are rounded to the szDecimals of that asset. For example, if szDecimals = 3 then 1.001 is a valid size but 1.0001 is not."
- The price `DECIMAL_PLACES` can be calculated by `MAX_DECIMALS - szDecimals`: "MAX_DECIMALS - szDecimals decimal places where MAX_DECIMALS is 6 for perps and 8 for spot."

Idea to set precisionMode to `DECIMAL_PLACES` and calculate both price and amount according to my 2 bulletpoints? Would make things a lot clearer and consistent with most other exchanges. And right now there does not seem to be a good way to get the decimal places. The current implementation of price_to_decimal assumes "Prices can have up to 5 significant figures", but seems to ignore "but no more than MAX_DECIMALS - szDecimals decimal places where MAX_DECIMALS is 6 for perps and 8 for spot". Also the 2 different types of precisions for amount and price are not ideal?
@gaardiolor 

That's a good idea, but I think we still need to normalize the price to significant digits first, then adjust the decimal places. Otherwise, the server would return the error: `hyperliquid {"status":"ok","response":{"type":"order","data":{"statuses":[{"error":"Price must be divisible by tick size. asset=28"}]}}}`.
Hi @sc0Vu , I'm missing something here, I'm not getting that from their documentation. What did you do exactly to get that error?
Hi @gaardiolor, 

I try to send the `FTM/USDC:USDC` order with 6 decimal places (MAX_DECIMAL - szDecimal, which is 6 - 0) with modified `priceToPrecision`, eg. 
`n hyperliquid createOrder FTM/USDC:USDC limit buy 100 0.379111 --test`

```JS
// modified priceToPrecision
// const result = this.decimalToPrecision(price, ROUND, market['precision']['price'], SIGNIFICANT_DIGITS, this.paddingMode);
const decimalParsedResult = this.decimalToPrecision(price, ROUND, 6, DECIMAL_PLACES, this.paddingMode);
```
Ah yes, understood. In this case a price of
0.123456 allows for 5 decimal places
0.012345 allows for 6 decimal places
1.12345 allows for 4 decimal places

And as a baseline, in the markets object, we could assume the 'mid' price returned by the hyperliquid api to do the calculation? 

The only issue would be when price really hovers around 1/10/100/1000/10000 .. not much we can do about that. I think I noticed that ccxt create_order fixes this in these edgecases.  
@gaardiolor I've open a PR to switch DECIMAL_PLACES precision mode, that would be great if you can take a look (https://github.com/ccxt/ccxt/pull/23560). BTW, I can't place spot order locally, still debug,
Hi @sc0Vu,

I think there are some mistakes regarding the precisions (number of digits after the '.') in [hyperliquid.json](https://github.com/sc0Vu/ccxt/blob/update-hyperliquid/ts/src/test/static/markets/hyperliquid.json):

"MEME/USDC:USDC": 
- amount is set to 0, should be 0: correct
- price is set to 6, should be 6: correct

"BTC/USDC:USDC"
- amount is set to 5, should be 5, correct
- price is set to 1, should be 0, incorrect

"SOL/USDC:USDC"
- amount is set to 2, should be 2, correct
- price is set to 4, should be 2, incorrect

"ETH/USDC:USDC"
- amount is set to 4, should be 4, correct
- price is set to 2, should be 1, incorrect

"ADA/USDC:USDC"
- amount is set to 0, should be 0, correct
- price is set to 6, should be 5, incorrect

"PURR/USDC"
- amount is set to 2, should be 0, incorrect
- price is set to 6, should be 5, incorrect
 
For "PURR/USDC" (the only spot pair in the test), I noticed that that 'szDecimals is missing from e.markets['PURR/USDC']['info'], but it's present in the hyperliquid api response.

I'm also not sure where you account for the max of 5 significant figures. For example [here](https://github.com/sc0Vu/ccxt/blob/94837cc51eea2c63d4ef54be8143f06fa25ef0c4/ts/src/hyperliquid.ts#L523) and [here](https://github.com/sc0Vu/ccxt/blob/94837cc51eea2c63d4ef54be8143f06fa25ef0c4/ts/src/hyperliquid.ts#L617) I see that you're doing  `8 - amountPrecision` and `6 - amountPrecision`, but it's not that simple. You'd get 1 for BTC, but because the price before the '.' is 5 digits already (equal to the max significant numbers), it's actually 0. Btw, from the looks of it Hyperliquid doesn't support BTC going over 100k, I'll check that with them.

So you have to account for the price. Here's where I noticed fetch_tickers needs a fix, it doesn't list any price for perps:

```
print(tickers['PURR/USDC']['close'])
0.164595
print(tickers['BTC/USDC:USDC']['close'])
None
```

The prices needed to set the base precision for the markets object are also returned in the same api call used for getting the markets (`metaAndAssetCtxs` & `spotMetaAndAssetCtxs`) .

There may be a more beautiful math approach, but for calculating the base precision for the markets object, this seems to work in python:
```
def calculate_price_decimals(szdecimals: int, price: float, max_decimals) -> int:
    """
    :param szdecimals: The szDecimals returned by the hyperliquid API
    :param price: The price to perform this calculation on
    :param max_decimals: 6 for perps, 8 for spot
    :return: the precision, meaning the max supported digits after the '.'
    """
    # Ensure price is stripped of leading and trailing spaces and leading zeroes
    price = f"{price:.10f}".strip().lstrip('0')

    # Calculate the amount of digits before the dot
    digits_before_dot = len(price.split('.')[0])

    # Calculate the amount of zeroes after the dot. Only relevant if there are no digits before the dot, set to 0 if there are
    zeroes_after_dot = len(price.split('.')[1]) - len(price.split('.')[1].lstrip('0')) if digits_before_dot == 0 else 0

    # Calculate the price decimals.
    if digits_before_dot > 0:
        price_decimals = min(5 - digits_before_dot, max_decimals - szdecimals)
    else:
        price_decimals = min(5 + zeroes_after_dot, max_decimals - szdecimals)
    return price_decimals
```

hope that helps!



@gaardiolor The price precision was calculated by MAX_DECIMALS - szDecimal, it's the maximum value. Not sure how to correct that value. I've push some updates, lmk if you find anything.

Commit message: Removed network upgrade comments
Removed unnecessary variable declaration

Patch to review:
diff --git a/ts/src/hyperliquid.ts b/ts/src/hyperliquid.ts
index 96fbe0f1395a8..7722c3b120254 100644
--- a/ts/src/hyperliquid.ts
+++ b/ts/src/hyperliquid.ts
@@ -361,6 +361,47 @@ export default class hyperliquid extends Exchange {
         return this.parseMarkets (result);
     }
 
+    /**
+     * @method
+     * @name calculatePricePrecision
+     * @description Helper function to calculate the Hyperliquid DECIMAL_PLACES price precision
+     * @param {float} price the price to use in the calculation
+     * @param {int} amountPrecision the amountPrecision to use in the calculation
+     * @param {int} maxDecimals the maxDecimals to use in the calculation
+     * @returns {int} The calculated price precision
+     */
+    calculatePricePrecision (price: number, amountPrecision: number, maxDecimals: number) {
+        let pricePrecision = 0;
+        if (price === 0) {
+            // Significant digits is always 5 in this case
+            const significantDigits = 5;
+            // Integer digits is always 0 in this case (0 doesn't count)
+            const integerDigits = 0;
+            // Calculate the price precision
+            pricePrecision = Math.min (maxDecimals - amountPrecision, significantDigits - integerDigits);
+        } else if (price > 0 && price < 1) {
+            // Significant digits is always 5 in this case
+            const significantDigits = 5;
+            // Get the leading zeros after the decimal separator, add the significant digits
+            const leadingZerosAfterDecimalPoint = this.numberToString (Math.abs (Math.log10 (price)));
+            pricePrecision = Math.floor (this.parseNumber ((Precise.stringAdd (leadingZerosAfterDecimalPoint, this.numberToString (significantDigits)))));
+            // Calculate the price precision
+            pricePrecision = Math.min (maxDecimals - amountPrecision, pricePrecision);
+        } else {
+            const significantDigits = Math.max (5, Math.floor (Math.log10 (price)) + 1);
+            // Get the amount of numbers before the decimal separator
+            let integerDigits = 0;
+            if (price === 0) {
+                integerDigits = 1;
+            } else {
+                integerDigits = Math.floor (Math.log10 (price)) + 1;
+            }
+            // Calculate the price precision
+            pricePrecision = Math.min (maxDecimals - amountPrecision, significantDigits - integerDigits);
+        }
+        return pricePrecision;
+    }
+
     /**
      * @method
      * @name hyperliquid#fetchMarkets
@@ -451,6 +492,8 @@ export default class hyperliquid extends Exchange {
             const innerBaseTokenInfo = this.safeDict (baseTokenInfo, 'spec', baseTokenInfo);
             // const innerQuoteTokenInfo = this.safeDict (quoteTokenInfo, 'spec', quoteTokenInfo);
             const amountPrecision = this.safeInteger (innerBaseTokenInfo, 'szDecimals');
+            const price = this.safeNumber (extraData, 'midPx');
+            const pricePrecision = this.calculatePricePrecision (price, amountPrecision, 8);
             // const quotePrecision = this.parseNumber (this.parsePrecision (this.safeString (innerQuoteTokenInfo, 'szDecimals')));
             const baseId = this.numberToString (i + 10000);
             markets.push (this.safeMarketStructure ({
@@ -481,8 +524,8 @@ export default class hyperliquid extends Exchange {
                 'strike': undefined,
                 'optionType': undefined,
                 'precision': {
-                    'amount': amountPrecision, // decimal places
-                    'price': 8 - amountPrecision, // MAX_DECIMALS is 8
+                    'amount': amountPrecision,
+                    'price': pricePrecision,
                 },
                 'limits': {
                     'leverage': {
@@ -548,6 +591,8 @@ export default class hyperliquid extends Exchange {
         const taker = this.safeNumber (fees, 'taker');
         const maker = this.safeNumber (fees, 'maker');
         const amountPrecision = this.safeInteger (market, 'szDecimals');
+        const price = this.safeNumber (market, 'markPx', 0);
+        const pricePrecision = this.calculatePricePrecision (price, amountPrecision, 6);
         return this.safeMarketStructure ({
             'id': baseId,
             'symbol': symbol,
@@ -575,8 +620,8 @@ export default class hyperliquid extends Exchange {
             'strike': undefined,
             'optionType': undefined,
             'precision': {
-                'amount': amountPrecision, // decimal places
-                'price': 6 - amountPrecision, // MAX_DECIMALS is 6
+                'amount': amountPrecision,
+                'price': pricePrecision,
             },
             'limits': {
                 'leverage': {
@@ -1053,10 +1098,16 @@ export default class hyperliquid extends Exchange {
 
     priceToPrecision (symbol: string, price): string {
         const market = this.market (symbol);
-        // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
-        const result = this.decimalToPrecision (price, ROUND, 5, SIGNIFICANT_DIGITS, this.paddingMode);
-        const decimalParsedResult = this.decimalToPrecision (result, ROUND, market['precision']['price'], this.precisionMode, this.paddingMode);
-        return decimalParsedResult;
+        let significantDigits = this.parseToInt ('5');
+        if (price === 0) {
+            significantDigits = 5;
+        } else {
+            // Calculate the number of digits before the decimal separator. If larger than 5, take that
+            significantDigits = Math.max (5, Math.floor (Math.log10 (price)) + 1);
+        }
+        const result = this.decimalToPrecision (price, ROUND, significantDigits, SIGNIFICANT_DIGITS, this.paddingMode);
+        const maxDecimals = market['spot'] ? this.parseToInt ('8') : this.parseToInt ('6');
+        return this.decimalToPrecision (result, ROUND, maxDecimals - market['precision']['amount'], this.precisionMode, this.paddingMode);
     }
 
     hashMessage (message) {


Code review comments:
diff_hunk: @@ -1053,10 +1098,16 @@ export default class hyperliquid extends Exchange {
 
     priceToPrecision (symbol: string, price): string {
         const market = this.market (symbol);
-        // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size
-        const result = this.decimalToPrecision (price, ROUND, 5, SIGNIFICANT_DIGITS, this.paddingMode);
-        const decimalParsedResult = this.decimalToPrecision (result, ROUND, market['precision']['price'], this.precisionMode, this.paddingMode);
-        return decimalParsedResult;
+        let significantDigits = this.parseToInt ('5');
+        if (price === 0) {
+            significantDigits = 5;
+        } else {
+            // Calculate the number of digits before the decimal separator. If larger than 5, take that
+            significantDigits = Math.max (5, Math.floor (Math.log10 (price)) + 1);
+        }
+        const result = this.decimalToPrecision (price, ROUND, significantDigits, SIGNIFICANT_DIGITS, this.paddingMode);
+        const maxDecimals = market['spot'] ? this.parseToInt ('8') : this.parseToInt ('6');
path: ts/src/hyperliquid.ts
text: @user1:
parseToInt isn't necessary here  

@author:
ok fixed

diff_hunk: @@ -361,6 +361,47 @@ export default class hyperliquid extends Exchange {
         return this.parseMarkets (result);
     }
 
+    /**
+     * @method
+     * @name calculatePricePrecision
+     * @description Helper function to calculate the Hyperliquid DECIMAL_PLACES price precision
+     * @param {float} price the price to use in the calculation
+     * @param {int} amountPrecision the amountPrecision to use in the calculation
+     * @param {int} maxDecimals the maxDecimals to use in the calculation
+     * @returns {int} The calculated price precision
+     */
+    calculatePricePrecision (price: number, amountPrecision: number, maxDecimals: number) {
+        let pricePrecision = 0;
+        if (price === 0) {
+            // Significant digits is always 5 in this case
+            const significantDigits = 5;
+            // Integer digits is always 0 in this case (0 doesn't count)
+            const integerDigits = 0;
+            // Calculate the price precision
+            pricePrecision = Math.min (maxDecimals - amountPrecision, significantDigits - integerDigits);
+        } else if (price > 0 && price < 1) {
+            // Significant digits is always 5 in this case
+            const significantDigits = 5;
+            // Get the leading zeros after the decimal separator, add the significant digits
+            const leadingZerosAfterDecimalPoint = this.numberToString (Math.abs (Math.log10 (price)));
+            pricePrecision = Math.floor (this.parseNumber ((Precise.stringAdd (leadingZerosAfterDecimalPoint, this.numberToString (significantDigits)))));
+            // Calculate the price precision
+            pricePrecision = Math.min (maxDecimals - amountPrecision, pricePrecision);
+        } else {
+            const significantDigits = Math.max (5, Math.floor (Math.log10 (price)) + 1);
+            // Get the amount of numbers before the decimal separator
+            let integerDigits = 0;
+            if (price === 0) {
+                integerDigits = 1;
+            } else {
+                integerDigits = Math.floor (Math.log10 (price)) + 1;
path: ts/src/hyperliquid.ts
text: @user2:
@author, as @user1 suggested, can't we use Precise instead of raw calculations? As mentioned earlier this does not work out of the box because log10 is not widely supported 

@author:
@user2  Yes, I can move away from log10. Precise can help with the integerDigits but (as far as I can tell) not with the leading zeros after the decimal part. (I need those to calculate the significantDigits in cases where price is between 0 and 1: 5 + leadingZeros)

Most simple approach to split the string - that gives the information to calculate both. 

See https://github.com/ccxt/ccxt/compare/master...gaardiolor:ccxt:hyperliquid-precision-string . Shall I move to that approach? 
If yes, want me to commit it here or is a new pull request cleaner?


@author:
Fixed, changed log10 to stringparsing