diff --git a/e2e/pages/strategy.spec.ts b/e2e/pages/strategy.spec.ts index c5a502e78..269220a4c 100644 --- a/e2e/pages/strategy.spec.ts +++ b/e2e/pages/strategy.spec.ts @@ -42,6 +42,21 @@ const testCases: CreateStrategyTestCase[] = [ fiat: '$3,334.42', }, }, + undercut: { + totalFiat: '$3,344.42', + buy: { + min: '1,501.50 DAI', + max: '1,501.50 DAI', + budget: '10.00 DAI', + fiat: '$10.00', + }, + sell: { + min: '1,698.30 DAI', + max: '1,698.30 DAI', + budget: '2.00 ETH', + fiat: '$3,334.42', + }, + }, }, }, { diff --git a/e2e/tests/strategy/recurring/duplicate.ts b/e2e/tests/strategy/recurring/duplicate.ts index 2dfbe7906..373f79435 100644 --- a/e2e/tests/strategy/recurring/duplicate.ts +++ b/e2e/tests/strategy/recurring/duplicate.ts @@ -6,6 +6,7 @@ import { } from './../../../utils/strategy'; import { NotificationDriver } from './../../../utils/NotificationDriver'; import { ManageStrategyDriver } from './../../../utils/strategy/ManageStrategyDriver'; +import { waitModalOpen } from '../../../utils/modal'; export const duplicateStrategyTest = (testCase: CreateStrategyTestCase) => { assertRecurringTestCase(testCase); @@ -16,6 +17,9 @@ export const duplicateStrategyTest = (testCase: CreateStrategyTestCase) => { const strategy = await manage.createStrategy(testCase.input); await strategy.clickManageEntry('manage-strategy-duplicateStrategy'); + const modal = await waitModalOpen(page); + await modal.getByTestId('duplicate-strategy-btn').click(); + await page.waitForURL('/strategies/create?strategy=*', { timeout: 10_000, }); diff --git a/e2e/tests/strategy/recurring/index.ts b/e2e/tests/strategy/recurring/index.ts index 85fe3e490..e7d690aa2 100644 --- a/e2e/tests/strategy/recurring/index.ts +++ b/e2e/tests/strategy/recurring/index.ts @@ -2,6 +2,7 @@ export { createRecurringStrategy } from './create'; export { deleteStrategyTest } from './delete'; export { depositStrategyTest } from './deposit'; export { duplicateStrategyTest } from './duplicate'; +export { undercutStrategyTest } from './undercut'; export { editPriceStrategyTest } from './edit'; export { pauseStrategyTest } from './pause'; export { renewStrategyTest } from './renew'; diff --git a/e2e/tests/strategy/recurring/undercut.ts b/e2e/tests/strategy/recurring/undercut.ts new file mode 100644 index 000000000..cc1e3b679 --- /dev/null +++ b/e2e/tests/strategy/recurring/undercut.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { + CreateStrategyTestCase, + MyStrategyDriver, + assertRecurringTestCase, +} from './../../../utils/strategy'; +import { NotificationDriver } from './../../../utils/NotificationDriver'; +import { ManageStrategyDriver } from './../../../utils/strategy/ManageStrategyDriver'; +import { waitModalOpen } from '../../../utils/modal'; + +export const undercutStrategyTest = (testCase: CreateStrategyTestCase) => { + assertRecurringTestCase(testCase); + const { base, quote } = testCase.input; + const output = testCase.output.undercut; + return test('Undercut', async ({ page }) => { + const manage = new ManageStrategyDriver(page); + const strategy = await manage.createStrategy(testCase.input); + await strategy.clickManageEntry('manage-strategy-duplicateStrategy'); + + const modal = await waitModalOpen(page); + await modal.getByTestId('undercut-strategy-btn').click(); + + await page.waitForURL('/strategies/create?strategy=*', { + timeout: 10_000, + }); + + await page.getByText('Create Strategy').click(); + await page.waitForURL('/', { timeout: 10_000 }); + + const notif = new NotificationDriver(page, 'create-strategy'); + await expect(notif.getTitle()).toHaveText('Success'); + await expect(notif.getDescription()).toHaveText( + 'New strategy was successfully created.' + ); + + const myStrategies = new MyStrategyDriver(page); + const strategies = await myStrategies.getAllStrategies(); + await expect(strategies).toHaveCount(2); + + const strategyUndercut = await myStrategies.getStrategy(2); + await expect(strategyUndercut.pair()).toHaveText(`${base}/${quote}`); + await expect(strategyUndercut.status()).toHaveText('Active'); + await expect(strategyUndercut.totalBudget()).toHaveText(output.totalFiat); + await expect(strategyUndercut.buyBudget()).toHaveText(output.buy.budget); + await expect(strategyUndercut.buyBudgetFiat()).toHaveText(output.buy.fiat); + await expect(strategyUndercut.sellBudget()).toHaveText(output.sell.budget); + await expect(strategyUndercut.sellBudgetFiat()).toHaveText( + output.sell.fiat + ); + + const buyTooltip = await strategyUndercut.priceTooltip('buy'); + await expect(buyTooltip.startPrice()).toHaveText(output.buy.min); + await buyTooltip.waitForDetached(); + + const sellTooltip = await strategyUndercut.priceTooltip('sell'); + await expect(sellTooltip.startPrice()).toHaveText(output.sell.min); + await sellTooltip.waitForDetached(); + }); +}; diff --git a/e2e/utils/strategy/CreateStrategyDriver.ts b/e2e/utils/strategy/CreateStrategyDriver.ts index ed789b630..0d8ebe642 100644 --- a/e2e/utils/strategy/CreateStrategyDriver.ts +++ b/e2e/utils/strategy/CreateStrategyDriver.ts @@ -34,6 +34,21 @@ export interface RecurringStrategyOutput { fiat: string; }; }; + undercut: { + totalFiat: string; + buy: { + min: string; + max: string; + budget: string; + fiat: string; + }; + sell: { + min: string; + max: string; + budget: string; + fiat: string; + }; + }; } export type RecurringStrategyTestCase = TestCase< RecurringStrategyInput, diff --git a/e2e/utils/strategy/MyStrategyDriver.ts b/e2e/utils/strategy/MyStrategyDriver.ts index 5ca5046a5..733f02b2e 100644 --- a/e2e/utils/strategy/MyStrategyDriver.ts +++ b/e2e/utils/strategy/MyStrategyDriver.ts @@ -51,6 +51,7 @@ export class MyStrategyDriver { minPrice: () => tooltip.getByTestId('min-price'), maxPrice: () => tooltip.getByTestId('max-price'), marginalPrice: () => tooltip.getByTestId('marginal-price'), + startPrice: () => tooltip.getByTestId('start-price'), waitForDetached: async () => { await this.page.mouse.move(0, 0); await tooltip.waitFor({ state: 'detached' }); diff --git a/src/assets/icons/cut.svg b/src/assets/icons/cut.svg new file mode 100644 index 000000000..8742ef682 --- /dev/null +++ b/src/assets/icons/cut.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/src/components/strategies/overview/strategyBlock/StrategyBlockManage.tsx b/src/components/strategies/overview/strategyBlock/StrategyBlockManage.tsx index 2b621fb31..12675360b 100644 --- a/src/components/strategies/overview/strategyBlock/StrategyBlockManage.tsx +++ b/src/components/strategies/overview/strategyBlock/StrategyBlockManage.tsx @@ -83,7 +83,11 @@ export const StrategyBlockManage: FC = ({ name: 'Duplicate Strategy', action: () => { carbonEvents.strategyEdit.strategyDuplicateClick(strategyEvent); - duplicate(strategy); + if (!isOverlapping) { + openModal('duplicateStrategy', { strategy }); + } else { + duplicate(strategy); + } }, }); } diff --git a/src/components/strategies/overview/strategyBlock/StrategyGraph.tsx b/src/components/strategies/overview/strategyBlock/StrategyGraph.tsx index 9079dbc98..ef9482a02 100644 --- a/src/components/strategies/overview/strategyBlock/StrategyGraph.tsx +++ b/src/components/strategies/overview/strategyBlock/StrategyGraph.tsx @@ -577,7 +577,7 @@ const OrderTooltip: FC = ({ strategy, buy }) => { Price - + {startPrice} {quote.symbol} diff --git a/src/components/strategies/overview/strategyBlock/utils.ts b/src/components/strategies/overview/strategyBlock/utils.ts index 3c8536120..661f4209e 100644 --- a/src/components/strategies/overview/strategyBlock/utils.ts +++ b/src/components/strategies/overview/strategyBlock/utils.ts @@ -32,7 +32,8 @@ export const getTooltipTextByStatus = ( }; const tooltipTextByStrategyEditOptionsId = { - duplicateStrategy: 'Create a new strategy with the same details', + duplicateStrategy: + 'Create a new strategy with the same details or undercut it', deleteStrategy: 'Delete the strategy and withdraw all associated funds to your wallet', pauseStrategy: 'Deactivate the strategy by nulling the prices', diff --git a/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.test.tsx b/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.test.tsx new file mode 100644 index 000000000..656d73f39 --- /dev/null +++ b/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.test.tsx @@ -0,0 +1,140 @@ +import { describe, test, expect } from 'vitest'; +import { SafeDecimal } from 'libs/safedecimal'; +import { deepCopy, getUndercutStrategy } from './utils'; + +type StrategyStatus = 'active' | 'noBudget' | 'paused' | 'inactive'; + +const baseStrategy = { + id: '', + idDisplay: '', + base: { + address: '', + decimals: 18, + symbol: 'ETH', + }, + quote: { + address: '', + decimals: 6, + symbol: 'USDC', + }, + order0: { + balance: '', + startRate: '', + endRate: '', + marginalRate: '', + }, + order1: { + balance: '', + startRate: '', + endRate: '', + marginalRate: '', + }, + status: 'active' as StrategyStatus, + encoded: { + id: '', + token0: '', + token1: '', + order0: { + y: '', + z: '', + A: '', + B: '', + }, + order1: { + y: '', + z: '', + A: '', + B: '', + }, + }, + roi: new SafeDecimal('0'), +}; + +let undercutStrategy = deepCopy(baseStrategy); + +describe('Test undercut strategy', () => { + test('getUndercutStrategy with 0.1% rate', () => { + const undercutDifference = 0.001; + + baseStrategy.order0.startRate = '0'; + baseStrategy.order0.endRate = '1700'; + baseStrategy.order1.startRate = '1800'; + baseStrategy.order1.endRate = '1900'; + undercutStrategy.order0.startRate = '0'; + undercutStrategy.order0.endRate = '1701.7'; + undercutStrategy.order1.startRate = '1798.2'; + undercutStrategy.order1.endRate = '1898.1'; + + expect(getUndercutStrategy(baseStrategy, undercutDifference)).toStrictEqual( + undercutStrategy + ); + }); + + test('getUndercutStrategy with 0.1% rate and a strategy with one limit order', () => { + const undercutDifference = 0.001; + + baseStrategy.order0.startRate = '0'; + baseStrategy.order0.endRate = '0'; + baseStrategy.order1.startRate = '1900'; + baseStrategy.order1.endRate = '1900'; + undercutStrategy.order0.startRate = '0'; + undercutStrategy.order0.endRate = '0'; + undercutStrategy.order1.startRate = '1898.1'; + undercutStrategy.order1.endRate = '1898.1'; + + expect(getUndercutStrategy(baseStrategy, undercutDifference)).toStrictEqual( + undercutStrategy + ); + }); + + test('getUndercutStrategy with 1% rate', () => { + const undercutDifference = 0.01; + + baseStrategy.order0.startRate = '1600'; + baseStrategy.order0.endRate = '1700'; + baseStrategy.order1.startRate = '1800'; + baseStrategy.order1.endRate = '1900'; + undercutStrategy.order0.startRate = '1616'; + undercutStrategy.order0.endRate = '1717'; + undercutStrategy.order1.startRate = '1782'; + undercutStrategy.order1.endRate = '1881'; + + expect(getUndercutStrategy(baseStrategy, undercutDifference)).toStrictEqual( + undercutStrategy + ); + }); + + test('getUndercutStrategy with negative rate', () => { + const undercutDifference = -1; + + baseStrategy.order0.startRate = '1600'; + baseStrategy.order0.endRate = '1700'; + baseStrategy.order1.startRate = '1800'; + baseStrategy.order1.endRate = '1900'; + undercutStrategy.order0.startRate = '1616'; + undercutStrategy.order0.endRate = '1717'; + undercutStrategy.order1.startRate = '1782'; + undercutStrategy.order1.endRate = '1881'; + + expect(() => + getUndercutStrategy(baseStrategy, undercutDifference) + ).toThrow(); + }); + + test('getUndercutStrategy with rate higher than 100%', () => { + const undercutDifference = 1.01; + + baseStrategy.order0.startRate = '1600'; + baseStrategy.order0.endRate = '1700'; + baseStrategy.order1.startRate = '1800'; + baseStrategy.order1.endRate = '1900'; + undercutStrategy.order0.startRate = '1616'; + undercutStrategy.order0.endRate = '1717'; + undercutStrategy.order1.startRate = '1782'; + undercutStrategy.order1.endRate = '1881'; + + expect(() => + getUndercutStrategy(baseStrategy, undercutDifference) + ).toThrow(); + }); +}); diff --git a/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.tsx b/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.tsx new file mode 100644 index 000000000..f8a37b9f6 --- /dev/null +++ b/src/libs/modals/modals/ModalDuplicateStrategy/ModalDuplicateStrategy.tsx @@ -0,0 +1,85 @@ +import { ReactComponent as IconCut } from 'assets/icons/cut.svg'; +import { ReactComponent as IconCopy } from 'assets/icons/copy.svg'; +import { Button } from 'components/common/button'; +import { useDuplicateStrategy } from 'components/strategies/create/useDuplicateStrategy'; +import { useModal } from 'hooks/useModal'; +import { ModalFC } from 'libs/modals/modals.types'; +import { ModalOrMobileSheet } from 'libs/modals/ModalOrMobileSheet'; +import { Strategy } from 'libs/queries'; +import { getUndercutStrategy } from './utils'; + +export type ModalDuplicateStrategyData = { + strategy: Strategy; +}; + +export const ModalDuplicateStrategy: ModalFC = ({ + id, + data: { strategy }, +}) => { + const { closeModal } = useModal(); + const { duplicate } = useDuplicateStrategy(); + const undercutDifference = 0.001; + + const undercutStrategy = () => { + const undercutStrategy = getUndercutStrategy(strategy, undercutDifference); + + duplicate(undercutStrategy); + closeModal(id); + }; + + const duplicateStrategy = () => { + duplicate(strategy); + closeModal(id); + }; + + const duplicateOptions = [ + { + icon: IconCopy, + title: 'Copy as Is', + onClick: duplicateStrategy, + description: + 'Duplicate the strategy with the existing values (price, budget)', + testId: 'duplicate-strategy-btn', + }, + { + icon: IconCut, + title: 'Undercut the Strategy', + onClick: undercutStrategy, + description: `Set prices at ${ + undercutDifference * 100 + }% tighter spread and try to get filled ahead`, + testId: 'undercut-strategy-btn', + }, + ]; + + return ( + +

Select your option.

+ + {duplicateOptions.map( + ({ icon: Icon, title, onClick, description, testId }) => ( +
+
+ +
+

{title}

+ +

+ {description} +

+
+ ) + )} +
+ ); +}; diff --git a/src/libs/modals/modals/ModalDuplicateStrategy/utils.ts b/src/libs/modals/modals/ModalDuplicateStrategy/utils.ts new file mode 100644 index 000000000..c3316490d --- /dev/null +++ b/src/libs/modals/modals/ModalDuplicateStrategy/utils.ts @@ -0,0 +1,54 @@ +import type { Strategy } from 'libs/queries'; +import { SafeDecimal } from 'libs/safedecimal'; + +const replaceDecimal = (key: string, value: any) => { + if (value instanceof SafeDecimal) + return { __type__: 'SafeDecimal', value: value.toString() }; + return value; +}; +const reviveDecimal = (key: string, value: any) => { + if ( + typeof value === 'object' && + value !== null && + value['__type__'] === 'SafeDecimal' + ) + return new SafeDecimal(value.value); + return value; +}; + +export const deepCopy = (obj: any): any => + JSON.parse(JSON.stringify(obj, replaceDecimal), reviveDecimal); + +export const getUndercutStrategy = ( + strategy: Strategy, + undercutDifference: number +): Strategy => { + const multiplyByRate = (rate: string, factor: number) => + new SafeDecimal(rate).times(factor).toString(); + + if (undercutDifference < 0 || undercutDifference > 1) + throw new Error( + 'undercutDifference must be less than or equal to 1, and higher than or equal to 0' + ); + + const undercutStrategy = deepCopy(strategy); + + undercutStrategy.order0.startRate = multiplyByRate( + strategy.order0.startRate, + 1 + undercutDifference + ); + undercutStrategy.order0.endRate = multiplyByRate( + strategy.order0.endRate, + 1 + undercutDifference + ); + undercutStrategy.order1.startRate = multiplyByRate( + strategy.order1.startRate, + 1 - undercutDifference + ); + undercutStrategy.order1.endRate = multiplyByRate( + strategy.order1.endRate, + 1 - undercutDifference + ); + + return undercutStrategy; +}; diff --git a/src/libs/modals/modals/index.ts b/src/libs/modals/modals/index.ts index 18114bff0..a6c9c51eb 100644 --- a/src/libs/modals/modals/index.ts +++ b/src/libs/modals/modals/index.ts @@ -40,6 +40,10 @@ import { ModalManageNotifications, ModalManageNotificationsData, } from './ModalManageNotifications'; +import { + ModalDuplicateStrategy, + ModalDuplicateStrategyData, +} from './ModalDuplicateStrategy/ModalDuplicateStrategy'; import { ModalConfirmWithdraw, ModalConfirmWithdrawData, @@ -63,6 +67,7 @@ export interface ModalSchema { restrictedCountry: undefined; genericInfo: ModalGenericInfoData; manageNotifications: ModalManageNotificationsData; + duplicateStrategy: ModalDuplicateStrategyData; confirmPauseStrategy: ModalConfirmPauseData; confirmWithdrawStrategy: ModalConfirmWithdrawData; confirmDeleteStrategy: ModalConfirmDeleteData; @@ -84,6 +89,7 @@ export const MODAL_COMPONENTS: TModals = { restrictedCountry: (props) => ModalRestrictedCountry(props), genericInfo: (props) => ModalGenericInfo(props), manageNotifications: (props) => ModalManageNotifications(props), + duplicateStrategy: (props) => ModalDuplicateStrategy(props), confirmPauseStrategy: (props) => ModalConfirmPause(props), confirmWithdrawStrategy: (props) => ModalConfirmWithdraw(props), confirmDeleteStrategy: (props) => ModalConfirmDelete(props),