Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,33 @@ export default class ServiceHyperliquidExchange extends ServiceBase {
const assetType = precision?.type;
const reduceOnly =
assetType === 'spot' ? false : Boolean(params.reduceOnly);
const formatOptionalTwapPrice = (
price: string | undefined,
fieldName: 'trigger' | 'stop',
) => {
if (!price) {
return undefined;
}
const formattedPrice = formatHlPrice(
price,
szDecimals,
assetType ?? 'perp',
);
if (!formattedPrice) {
throw new OneKeyLocalError(
`TWAP ${fieldName} price is too small for HL tick size`,
);
}
return formattedPrice;
};
const triggerPrice = formatOptionalTwapPrice(
params.triggerPrice,
'trigger',
);
const stopPrice = formatOptionalTwapPrice(params.stopPrice, 'stop');
Comment thread
Minnzen marked this conversation as resolved.
if (triggerPrice && typeof params.triggerAbove !== 'boolean') {
throw new OneKeyLocalError('TWAP trigger direction is required');
}
const twap = {
a: params.assetId,
b: params.isBuy,
Expand All @@ -1402,6 +1429,12 @@ export default class ServiceHyperliquidExchange extends ServiceBase {
m: params.minutes,
t: params.randomize,
};
const details = {
t: triggerPrice
? { p: triggerPrice, a: params.triggerAbove as boolean }
: null,
s: stopPrice ?? null,
};
const client = await this.getExchangeClientForTrading();
const context = await this._buildLogContext();
const requestPayload = {
Expand All @@ -1413,12 +1446,14 @@ export default class ServiceHyperliquidExchange extends ServiceBase {
minutes: params.minutes,
randomize: params.randomize,
},
details,
};

try {
const response = await convertHyperLiquidResponse(() =>
client.twapOrder({
twap,
details,
}),
);
defaultLogger.perp.hyperliquid.twapOrder({
Expand Down
66 changes: 46 additions & 20 deletions packages/kit/src/states/jotai/contexts/hyperliquid/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,20 @@ import { EModalPerpRoutes } from '@onekeyhq/shared/src/routes/perp';
import { getCurrentVisibilityState } from '@onekeyhq/shared/src/utils/appVisibility';
import { memoFn } from '@onekeyhq/shared/src/utils/cacheUtils';
import {
SCALE_ORDER_MIN_NOTIONAL,
getReduceOnlyOrderGuardError,
getReduceOnlyPositionMaxSize,
getReduceOnlyPositionSnapshotError,
getScaleOrderReferencePrice,
getScaleOrderSizeSkew,
} from '@onekeyhq/shared/src/utils/hyperliquidScaleOrderUtils';
import {
TWAP_MAX_DURATION_MINUTES,
TWAP_MIN_DURATION_MINUTES,
TWAP_MIN_ORDER_NOTIONAL,
getTwapTriggerAbove,
isTwapTotalNotionalValid,
isValidTwapDuration,
} from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils';
import {
getPerpsOrderBookTickOptionWithCache,
getPerpsOrderBookTickOptionsWithCache,
Expand Down Expand Up @@ -164,10 +171,6 @@ const MAX_LEDGER_UPDATES = 200;
const ACCOUNT_MODE_USER_WALLET_TIMEOUT_MS = platformEnv.isNative
? 60_000
: 15_000;
const TWAP_MIN_DURATION_MINUTES = 5;
const TWAP_MAX_DURATION_MINUTES = 1440;
const TWAP_MIN_ORDER_NOTIONAL = Number(SCALE_ORDER_MIN_NOTIONAL);
const TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS = 30;
const TWAP_SLICE_FILLS_MAX_COUNT = 2000;

const setAbstractionWithUserWalletTimeout = makeTimeoutPromise<
Expand Down Expand Up @@ -1676,6 +1679,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
triggerPrice: '',
executionPrice: '',
triggerReduceOnly: true,
twapTriggerPrice: '',
twapStopPrice: '',
};

// update limit price once using current atom snapshot.
Expand Down Expand Up @@ -1852,6 +1857,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
sizePercent: 0,
triggerPrice: '',
executionPrice: '',
twapTriggerPrice: '',
twapStopPrice: '',
};
// Spot doesn't have margin mode -- force to usd if currently set to margin
const currentPrefs = await perpsTradingPreferencesAtom.get();
Expand Down Expand Up @@ -2419,6 +2426,8 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
slValue: '',
triggerPrice: '',
executionPrice: '',
twapTriggerPrice: '',
twapStopPrice: '',
});
});

Expand Down Expand Up @@ -2876,11 +2885,7 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
}

const minutes = Number(formData.twapDurationMinutes ?? 0);
if (
!Number.isInteger(minutes) ||
minutes < TWAP_MIN_DURATION_MINUTES ||
minutes > TWAP_MAX_DURATION_MINUTES
) {
if (!isValidTwapDuration(minutes)) {
throw new OneKeyLocalError(
`TWAP duration must be ${TWAP_MIN_DURATION_MINUTES}-${TWAP_MAX_DURATION_MINUTES} minutes`,
);
Expand All @@ -2896,6 +2901,29 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
);
}

const triggerPrice = formData.twapTriggerPrice?.trim();
const stopPrice = formData.twapStopPrice?.trim();
let triggerAbove: boolean | undefined;
if (triggerPrice) {
triggerAbove = getTwapTriggerAbove({
triggerPrice,
markPrice: markPriceBN,
});
if (typeof triggerAbove !== 'boolean') {
throw new OneKeyLocalError(
'TWAP trigger price must be positive and differ from the market price',
);
}
}
Comment thread
Minnzen marked this conversation as resolved.
Outdated
if (stopPrice) {
const stopPriceBN = new BigNumber(stopPrice);
if (!stopPriceBN.isFinite() || stopPriceBN.lte(0)) {
throw new OneKeyLocalError(
'TWAP stop price must be a positive number',
);
}
}

const szDecimals = isSpot
? (activeTradeInstrument.universe?.baseSzDecimals ??
env.szDecimals ??
Expand All @@ -2922,19 +2950,14 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
throw new OneKeyLocalError('Order size is required');
}

const totalNotional = resolvedSizeBN.multipliedBy(markPriceBN);
const estimatedSlices = Math.max(
1,
Math.ceil((minutes * 60) / TWAP_ESTIMATED_SLICE_INTERVAL_SECONDS),
);
const averageSliceNotional =
totalNotional.dividedBy(estimatedSlices);
if (
!averageSliceNotional.isFinite() ||
averageSliceNotional.lt(TWAP_MIN_ORDER_NOTIONAL)
!isTwapTotalNotionalValid({
size: resolvedSizeBN,
price: markPriceBN,
})
) {
throw new OneKeyLocalError(
'TWAP order size is too small for this duration',
`TWAP total notional must be at least ${TWAP_MIN_ORDER_NOTIONAL} USDC`,
);
}

Expand Down Expand Up @@ -2977,6 +3000,9 @@ class ContextJotaiActionsHyperliquid extends ContextJotaiActionsBase {
reduceOnly,
minutes,
randomize: formData.twapRandomize ?? true,
triggerPrice,
triggerAbove,
stopPrice,
szDecimals,
},
);
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/states/jotai/contexts/hyperliquid/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ export interface ITradingFormData {
twapDurationMinutes?: string;
twapRandomize?: boolean;
twapReduceOnly?: boolean;
twapTriggerPrice?: string;
twapStopPrice?: string;
}

export const { atom: tradingFormAtom, use: useTradingFormAtom } =
Expand Down Expand Up @@ -347,6 +349,8 @@ export const { atom: tradingFormAtom, use: useTradingFormAtom } =
twapDurationMinutes: '10',
twapRandomize: true,
twapReduceOnly: false,
twapTriggerPrice: '',
twapStopPrice: '',
});

export type ITradingFormOrderPriceParams = Pick<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
} from '@onekeyhq/kit-bg/src/states/jotai/atoms';
import { ETranslations } from '@onekeyhq/shared/src/locale';
import { formatTime } from '@onekeyhq/shared/src/utils/dateUtils';
import { getTwapElapsedMs } from '@onekeyhq/shared/src/utils/hyperliquidTwapUtils';
import type { INumberFormatProps } from '@onekeyhq/shared/src/utils/numberUtils';
import {
formatLocalizedNumberString,
numberFormat,
} from '@onekeyhq/shared/src/utils/numberUtils';
import { getValidPriceDecimals } from '@onekeyhq/shared/src/utils/perpsUtils';
import type { ITwapHistoryRecord } from '@onekeyhq/shared/types/hyperliquid/sdk';

import { PerpTestIDs } from '../../../testIDs';
import { getOrderAssetDisplayName } from '../utils';
Expand All @@ -35,6 +37,7 @@ const valueFormatter: INumberFormatProps = {

interface IMobileTwapOpenOrdersRowProps {
order: IPerpsActiveTwapOrder;
status: ITwapHistoryRecord['status']['status'];
onCancelOrder: () => void;
}

Expand Down Expand Up @@ -62,6 +65,15 @@ function formatTotalDuration(minutes: number) {
return `${hours}h ${remainingMinutes}m`;
}

function formatTwapPrice(price?: string | null) {
const priceBN = new BigNumber(price ?? '');
if (!priceBN.isFinite() || priceBN.lte(0)) {
return '--';
}
const priceValue = priceBN.toFixed(getValidPriceDecimals(priceBN.toFixed()));
return formatLocalizedNumberString(priceValue);
}

function MobileTwapInfoRow({ label, value }: { label: string; value: string }) {
return (
<XStack width="100%" alignItems="center" justifyContent="space-between">
Expand All @@ -80,17 +92,20 @@ function MobileTwapInfoRow({ label, value }: { label: string; value: string }) {
}

const MobileTwapOpenOrdersRow = memo(
({ order, onCancelOrder }: IMobileTwapOpenOrdersRowProps) => {
({ order, status, onCancelOrder }: IMobileTwapOpenOrdersRowProps) => {
const intl = useIntl();
const { twapId, state } = order;
const [now, setNow] = useState(Date.now());
const [spotDisplayMap] = useSpotPairDisplayMapAtom();
const [spotPairDisplayNameMap] = useSpotPairDisplayNameMapAtom();

useEffect(() => {
if (status === 'waitingForTrigger') {
return undefined;
}
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
}, [status]);

const assetSymbol = useMemo(
() =>
Expand Down Expand Up @@ -129,15 +144,19 @@ const MobileTwapOpenOrdersRow = memo(
.multipliedBy(100)
.toFixed(0)
: undefined;
const progressText =
totalSize.gt(0) && executedSize.gte(0)
? `${numberFormat(
executedSize.toFixed(),
balanceFormatter,
)} / ${numberFormat(totalSize.toFixed(), balanceFormatter)}${
progressPercent ? ` · ${progressPercent}%` : ''
}`
: `${state.executedSz} / ${state.sz}`;
const isWaitingForTrigger = status === 'waitingForTrigger';
let progressText = '--';
if (!isWaitingForTrigger) {
progressText =
totalSize.gt(0) && executedSize.gte(0)
? `${numberFormat(
executedSize.toFixed(),
balanceFormatter,
)} / ${numberFormat(totalSize.toFixed(), balanceFormatter)}${
progressPercent ? ` · ${progressPercent}%` : ''
}`
: `${state.executedSz} / ${state.sz}`;
}
const minuteUnit = intl
.formatMessage({ id: ETranslations.Limit_expire_minutes })
.toLowerCase();
Expand All @@ -146,25 +165,31 @@ const MobileTwapOpenOrdersRow = memo(
? ` · ${intl.formatMessage({ id: ETranslations.global_randomized })}`
: ''
}`;
const elapsedMs = Math.min(
Math.max(now - state.timestamp, 0),
state.minutes * 60_000,
);
const elapsedMs = getTwapElapsedMs({
status,
timestamp: state.timestamp,
now,
minutes: state.minutes,
});
return {
progressText,
avgPriceFormatted: avgPriceValue
? formatLocalizedNumberString(avgPriceValue)
: '--',
executedValueFormatted: numberFormat(state.executedNtl, valueFormatter),
triggerPriceFormatted: formatTwapPrice(state.trigger?.px),
stopPriceFormatted: formatTwapPrice(state.stopPx),
execution,
runningTimeText: `${formatElapsedDuration(
elapsedMs,
)} / ${formatTotalDuration(state.minutes)}`,
runningTimeText: isWaitingForTrigger
? '--'
: `${formatElapsedDuration(elapsedMs)} / ${formatTotalDuration(
state.minutes,
)}`,
reduceOnlyText: state.reduceOnly
? intl.formatMessage({ id: ETranslations.perp_yes__title })
: intl.formatMessage({ id: ETranslations.perp_no__title }),
};
}, [intl, now, state]);
}, [intl, now, state, status]);

const sideText = useMemo(() => {
if (state.side === 'B') {
Expand All @@ -177,6 +202,12 @@ const MobileTwapOpenOrdersRow = memo(
: intl.formatMessage({ id: ETranslations.perp_short });
}, [intl, state.reduceOnly, state.side]);
const typeColor = state.side === 'B' ? '$green11' : '$red11';
const statusText = intl.formatMessage({
id:
status === 'waitingForTrigger'
? ETranslations.global_pending
: ETranslations.perp_twap_status_activated__title,
});

return (
<ListItem
Expand Down Expand Up @@ -237,6 +268,25 @@ const MobileTwapOpenOrdersRow = memo(
})}
value={baseInfo.execution}
/>
<MobileTwapInfoRow
label={intl.formatMessage({ id: ETranslations.global_status })}
value={statusText}
/>
<MobileTwapInfoRow
label={intl.formatMessage({
id: ETranslations.dexmarket_pro_trigger_price,
})}
value={baseInfo.triggerPriceFormatted}
/>
<MobileTwapInfoRow
label={intl.formatMessage({
id:
state.side === 'B'
? ETranslations.perp_scale_upper_price_label__title
: ETranslations.perp_scale_lower_price_label__title,
})}
value={baseInfo.stopPriceFormatted}
/>
<MobileTwapInfoRow
label={intl.formatMessage({
id: ETranslations.perp_twap_avg_filled_price__title,
Expand Down
Loading
Loading