feat(markets): new price monitoring bounds panel (#5996)

Co-authored-by: Dariusz Majcherczyk <dariusz.majcherczyk@gmail.com>
This commit is contained in:
Art 2024-03-15 17:22:58 +01:00 committed by GitHub
parent 72df08851c
commit 88f99031d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 315 additions and 132 deletions

View File

@ -6,6 +6,7 @@ import {
LiquiditySLAParametersInfoPanel,
MarginScalingFactorsPanel,
PriceMonitoringBoundsInfoPanel,
PriceMonitoringSettingsInfoPanel,
SuccessionLineInfoPanel,
getDataSourceSpecForSettlementData,
getDataSourceSpecForTradingTermination,
@ -21,7 +22,6 @@ import {
RiskModelInfoPanel,
SettlementAssetInfoPanel,
} from '@vegaprotocol/markets';
import { MarketInfoTable } from '@vegaprotocol/markets';
import type { DataSourceFragment } from '@vegaprotocol/markets';
import isEqual from 'lodash/isEqual';
@ -74,27 +74,14 @@ export const MarketDetails = ({ market }: { market: MarketInfoWithData }) => {
<MarginScalingFactorsPanel market={market} />
<h2 className={headerClassName}>{t('Risk factors')}</h2>
<RiskFactorsInfoPanel market={market} />
{(market.data?.priceMonitoringBounds || []).map((trigger, i) => (
<>
<h2 className={headerClassName}>
{t('Price monitoring bounds %s', [(i + 1).toString()])}
</h2>
<PriceMonitoringBoundsInfoPanel
market={market}
triggerIndex={i + 1}
/>
</>
))}
{(market.priceMonitoringSettings?.parameters?.triggers || []).map(
(trigger, i) => (
<>
<h2 className={headerClassName}>
{t('Price monitoring settings %s', [(i + 1).toString()])}
</h2>
<MarketInfoTable data={trigger} key={i} />
</>
)
)}
<h2 className={headerClassName}>{t('Price monitoring bounds')}</h2>
<div className="mt-3">
<PriceMonitoringBoundsInfoPanel market={market} />
</div>
<h2 className={headerClassName}>{t('Price monitoring settings')}</h2>
<div className="mt-3">
<PriceMonitoringSettingsInfoPanel market={market} />
</div>
<h2 className={headerClassName}>{t('Liquidation strategy')}</h2>
<LiquidationStrategyInfoPanel market={market} />
<h2 className={headerClassName}>{t('Liquidity monitoring')}</h2>

View File

@ -19,6 +19,7 @@ import {
getSigners,
MarginScalingFactorsPanel,
marketInfoProvider,
PriceMonitoringSettingsInfoPanel,
} from '@vegaprotocol/markets';
import {
Button,
@ -245,39 +246,23 @@ export const ProposalMarketData = ({ proposalId }: { proposalId: string }) => {
parentMarket={parentMarketData}
/>
{showParentPriceMonitoringBounds &&
(
parentMarketData?.priceMonitoringSettings?.parameters
?.triggers || []
).map((_, triggerIndex) => (
<>
<h2 className={marketDataHeaderStyles}>
{t(`Parent price monitoring bounds ${triggerIndex + 1}`)}
</h2>
<div className="text-vega-dark-300 line-through">
<PriceMonitoringBoundsInfoPanel
market={parentMarketData}
triggerIndex={triggerIndex}
/>
</div>
</>
))}
{(
marketData.priceMonitoringSettings?.parameters?.triggers || []
).map((_, triggerIndex) => (
{showParentPriceMonitoringBounds && (
// shows bounds for parent market
<>
<h2 className={marketDataHeaderStyles}>
{t(`Price monitoring bounds ${triggerIndex + 1}`)}
{t('Parent price monitoring bounds')}
</h2>
<PriceMonitoringBoundsInfoPanel
market={marketData}
triggerIndex={triggerIndex}
/>
<div className="text-vega-dark-300 line-through">
<PriceMonitoringBoundsInfoPanel market={parentMarketData} />
</div>
</>
))}
)}
<h2 className={marketDataHeaderStyles}>
{t('Price monitoring settings')}
</h2>
<PriceMonitoringSettingsInfoPanel market={marketData} />
<h2 className={marketDataHeaderStyles}>
{t('Liquidity monitoring parameters')}
</h2>

View File

@ -6,6 +6,7 @@ from fixtures.market import setup_continuous_market
from conftest import init_page, init_vega, risk_accepted_setup, cleanup_container
market_title_test_id = "accordion-title"
market_accordion_content = "accordion-content"
@pytest.fixture(scope="module")
@ -225,18 +226,16 @@ def test_market_info_risk_factors(page: Page):
def test_market_info_price_monitoring_bounds(page: Page):
# 6002-MDET-211
page.get_by_test_id(market_title_test_id).get_by_text(
"Price monitoring bounds 1"
"Price monitoring bounds"
).click()
expect(page.locator("p.col-span-1").nth(0)).to_contain_text(
"99.9999% probability price bounds"
)
expect(page.locator("p.col-span-1").nth(1)
).to_contain_text("Within 86,400 seconds")
fields = [
["Highest Price", "138.66685 BTC"],
["Lowest Price", "83.11038 BTC"],
]
validate_info_section(page, fields)
expect(page.get_by_test_id(market_accordion_content).locator(
".w-full").nth(1)).to_contain_text("99.9999%")
expect(page.get_by_test_id(market_accordion_content).locator(".w-full").nth(2)
).to_contain_text("within 1d")
expect(page.get_by_test_id(market_accordion_content).locator(".text-left")
).to_contain_text("83.11038 BTC")
expect(page.get_by_test_id(market_accordion_content).locator(".text-right")
).to_contain_text("138.66685 BTC")
def test_market_info_liquidity_monitoring_parameters(page: Page):

View File

@ -87,6 +87,7 @@
"oracleInMarkets_other": "Oracle in {{count}} markets",
"oracleInMarkets": "Oracle in {{count}} markets",
"Price monitoring bounds {{index}}": "Price monitoring bounds {{index}}",
"Price monitoring bounds": "Price monitoring bounds",
"Price oracle {{index}}": "Price oracle {{index}}",
"Probability level for price projection, e.g. value of 0.95 will result in a price range such that over the specified projection horizon, the prices observed in the market should be in that range 95% of the time.": "Probability level for price projection, e.g. value of 0.95 will result in a price range such that over the specified projection horizon, the prices observed in the market should be in that range 95% of the time.",
"Probability level used in <0>Expected Shortfall</0> calculation when obtaining Risk Factor Long and Risk Factor Short": "Probability level used in <0>Expected Shortfall</0> calculation when obtaining Risk Factor Long and Risk Factor Short",
@ -97,7 +98,7 @@
"Proposal": "Proposal",
"Propose a change to market": "Propose a change to market",
"Read more": "Read more",
"Results in {{auctionExtensionSecs}} seconds auction if breached": "Results in {{auctionExtensionSecs}} seconds auction if breached",
"Results in {{duration}} auction if breached": "Results in {{duration}} auction if breached",
"Risk factors": "Risk factors",
"Risk model": "Risk model",
"Settlement": "Settlement",
@ -166,6 +167,12 @@
"View settlement schedule specification": "View settlement schedule specification",
"View specification": "View specification",
"View termination specification": "View termination specification",
"Within {{horizonSecs}} seconds": "Within {{horizonSecs}} seconds",
"Weighted": "Weighted"
"Weighted": "Weighted",
"within {{duration}}": "within {{duration}}",
"Minimum price that isn't currently breaching the specified price monitoring trigger": "Minimum price that isn't currently breaching the specified price monitoring trigger",
"Maximum price that isn't currently breaching the specified price monitoring trigger": "Maximum price that isn't currently breaching the specified price monitoring trigger",
"{{probability}} of prices must be within the bounds for {{duration}}": "{{probability}} of prices must be within the bounds for {{duration}}",
"No price monitoring bounds detected.": "No price monitoring bounds detected.",
"triggers_one": "Trigger",
"triggers_other": "Chain of {{count}} triggers"
}

View File

@ -0,0 +1,19 @@
{
"duration_days_one": "{{count}} day",
"duration_days_other": "{{count}} days",
"duration_hours_one": "{{count}} hour",
"duration_hours_other": "{{count}} hours",
"duration_minutes_one": "{{count}} minute",
"duration_minutes_other": "{{count}} minutes",
"duration_seconds_one": "{{count}} second",
"duration_seconds_other": "{{count}} seconds",
"duration_days_compact_one": "{{count}}d",
"duration_days_compact_other": "{{count}}d",
"duration_hours_compact_one": "{{count}}h",
"duration_hours_compact_other": "{{count}}h",
"duration_minutes_compact_one": "{{count}}m",
"duration_minutes_compact_other": "{{count}}m",
"duration_seconds_compact_one": "{{count}}s",
"duration_seconds_compact_other": "{{count}}s"
}

View File

@ -221,27 +221,11 @@ export const MarketInfoAccordion = ({
title={t('Risk factors')}
content={<RiskFactorsInfoPanel market={market} />}
/>
{(market.priceMonitoringSettings?.parameters?.triggers || []).map(
(_, triggerIndex) => {
const id = `trigger-${triggerIndex}`;
return (
<AccordionItem
key={id}
itemId={id}
title={t('Price monitoring bounds {{index}}', {
index: triggerIndex + 1,
})}
content={
<PriceMonitoringBoundsInfoPanel
market={market}
triggerIndex={triggerIndex}
key={id}
/>
}
/>
);
}
)}
<AccordionItem
itemId="trigger"
title={t('Price monitoring bounds')}
content={<PriceMonitoringBoundsInfoPanel market={market} />}
/>
<AccordionItem
itemId="liquidation-strategy"
title={t('Liquidation strategy')}

View File

@ -24,10 +24,10 @@ import {
import {
addDecimalsFormatNumber,
determinePriceStep,
formatNumber,
formatNumberPercentage,
getDateTimeFormat,
getMarketExpiryDateFormatted,
toBigNum,
} from '@vegaprotocol/utils';
import type { Get } from 'type-fest';
import { MarketInfoTable } from './info-key-value-table';
@ -88,6 +88,12 @@ import { formatDuration } from 'date-fns';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useT } from '../../use-t';
import { isPerpetual } from '../../product';
import omit from 'lodash/omit';
import orderBy from 'lodash/orderBy';
import groupBy from 'lodash/groupBy';
import min from 'lodash/min';
import sum from 'lodash/sum';
import { useDuration } from '@vegaprotocol/react-helpers';
type MarketInfoProps = {
market: MarketInfo;
@ -779,13 +785,30 @@ export const RiskFactorsInfoPanel = ({
return <MarketInfoTable data={data} parentData={parentData} unformatted />;
};
export const PriceMonitoringBoundsInfoPanel = ({
market,
triggerIndex,
}: MarketInfoProps & {
triggerIndex: number;
}) => {
type TriggerInfo = {
maxValidPrice: BigNumber;
minValidPrice: BigNumber;
referencePrice: BigNumber;
horizonSecs: number;
probability: number;
auctionExtensionSecs: number;
};
/** Calculates a trigger info group signature. */
const triggerInfoGroupIteratee = (t: TriggerInfo) =>
`${t.horizonSecs}|${
t.probability
}|${t.minValidPrice.toString()}|${t.maxValidPrice.toString()}`;
type ExtendedTriggerInfo = Omit<TriggerInfo, 'auctionExtensionSecs'> & {
minAuctionExtensionSecs: number;
maxAuctionExtensionSecs: number;
};
export const PriceMonitoringBoundsInfoPanel = ({ market }: MarketInfoProps) => {
const t = useT();
const duration = useDuration();
const compactDuration = useDuration('compact');
const { data } = useDataProvider({
dataProvider: marketDataProvider,
variables: { marketId: market.id },
@ -793,45 +816,159 @@ export const PriceMonitoringBoundsInfoPanel = ({
const quoteUnit = getQuoteName(market);
const bounds = data?.priceMonitoringBounds?.[triggerIndex];
const trigger = bounds?.trigger;
let triggers = Object.entries(
groupBy(
data?.priceMonitoringBounds?.map((b) => ({
...omit(b.trigger, '__typename'),
maxValidPrice: toBigNum(b.maxValidPrice, market.decimalPlaces),
minValidPrice: toBigNum(b.minValidPrice, market.decimalPlaces),
referencePrice: toBigNum(b.referencePrice, market.decimalPlaces),
})),
triggerInfoGroupIteratee
)
).map(([_, ts]) => {
const info = omit(ts[0], 'auctionExtensionSecs');
const auctions = ts.map((t) => t.auctionExtensionSecs);
const minAuctionExtensionSecs = min(auctions) as number;
const maxAuctionExtensionSecs = sum(auctions);
const extendedInfo: ExtendedTriggerInfo = {
...info,
minAuctionExtensionSecs,
maxAuctionExtensionSecs,
};
return extendedInfo;
});
if (!trigger) {
return null;
triggers = orderBy(
triggers,
[
(bd) => bd.probability,
(bd) => bd.horizonSecs,
(bd) => bd.minAuctionExtensionSecs,
(bd) => bd.maxAuctionExtensionSecs,
],
['desc', 'asc', 'asc', 'asc']
);
if (!triggers || triggers.length === 0) {
return <div>{t('No price monitoring bounds detected.')}</div>;
}
return (
<>
<div className="mb-2 grid grid-cols-2 text-xs">
<p className="col-span-1">
{t('{{probability}} probability price bounds', {
probability: formatNumberPercentage(
new BigNumber(trigger.probability).times(100)
),
})}
</p>
<p className="col-span-1 text-right">
{t('Within {{horizonSecs}} seconds', {
horizonSecs: formatNumber(trigger.horizonSecs),
const price = (price: string, direction: 'min' | 'max') => (
<div
className={classNames(
'rounded px-1 py-[1px] bg-vega-clight-500 dark:bg-vega-cdark-500 relative',
'after:absolute after:content-[" "] after:z-10',
'after:block after:w-3 after:h-3 after:bg-vega-clight-500 dark:after:bg-vega-cdark-500 after:rotate-45 after:-translate-y-1/2',
{
'after:top-1/2 after:right-[-6px]': direction === 'min',
'after:top-1/2 after:left-[-6px]': direction === 'max',
}
)}
>
<div
className={classNames('text-[10px]', {
'text-left': direction === 'min',
'text-right': direction === 'max',
})}
>
{price} <span>{quoteUnit}</span>
</div>
</div>
);
return triggers.map((trigger, index) => {
const probability = formatNumberPercentage(
new BigNumber(trigger.probability).times(100)
);
const within = compactDuration(trigger.horizonSecs * 1000);
return (
<div key={index} className="mb-2 border-b border-b-vega-clight-500">
<div className="font-mono text-xs flex">
{/** MIN PRICE */}
<Tooltip
description={t(
"Minimum price that isn't currently breaching the specified price monitoring trigger"
)}
>
{price(trigger.minValidPrice.toString(10), 'min')}
</Tooltip>
{/** TRIGGERS WHEN */}
<Tooltip
description={t(
'{{probability}} of prices must be within the bounds for {{duration}}',
{
probability: probability,
duration: within,
}
)}
>
<div aria-hidden className="w-full text-center text-[10px]">
<div className="border-b-[2px] border-dashed border-vega-clight-500 dark:border-vega-cdark-500 w-full h-1/2 translate-y-[1px]">
{probability}
</div>
<div className="w-full">
{t('within {{duration}}', {
duration: within,
})}
</div>
</div>
</Tooltip>
{/** MAX PRICE */}
<Tooltip
description={t(
"Maximum price that isn't currently breaching the specified price monitoring trigger"
)}
>
{price(trigger.maxValidPrice.toString(10), 'max')}
</Tooltip>
</div>
<p className="my-2 text-xs leading-none">
{t('Results in {{duration}} auction if breached', {
duration:
trigger.minAuctionExtensionSecs !==
trigger.maxAuctionExtensionSecs
? `${duration(
trigger.minAuctionExtensionSecs * 1000
)} ~ ${duration(trigger.maxAuctionExtensionSecs * 1000)}`
: duration(trigger.minAuctionExtensionSecs * 1000),
})}
</p>
</div>
{bounds && (
<MarketInfoTable
data={{
highestPrice: bounds.maxValidPrice,
lowestPrice: bounds.minValidPrice,
}}
decimalPlaces={market.decimalPlaces}
assetSymbol={quoteUnit}
/>
)}
<p className="mt-2 text-xs">
{t('Results in {{auctionExtensionSecs}} seconds auction if breached', {
auctionExtensionSecs: trigger.auctionExtensionSecs.toString(),
})}
</p>
</>
);
});
};
export const PriceMonitoringSettingsInfoPanel = ({
market,
className,
}: MarketInfoProps & { className?: classNames.Argument }) => {
const t = useT();
const triggers = groupBy(
market.priceMonitoringSettings?.parameters?.triggers?.map((t) =>
omit(t, '__typename')
) || [],
(t) => `${t.horizonSecs}|${t.probability}|${t.auctionExtensionSecs}`
);
return (
<dl className="grid grid-cols-3 md:grid-cols-6 gap-3">
{Object.entries(triggers).map(([_, trigger], i) => (
<span className="border p-3 rounded">
<dt className="text-sm font-bold">
{t('triggers', {
count: trigger.length,
})}
</dt>
<dt>
<MarketInfoTable data={trigger[0]} />
</dt>
</span>
))}
</dl>
);
};

View File

@ -14,3 +14,4 @@ export * from './use-yesterday';
export * from './use-previous';
export { useScript } from './use-script';
export { useUserAgent } from './use-user-agent';
export * from './use-duration';

View File

@ -0,0 +1,53 @@
import { convertToDuration } from '@vegaprotocol/utils';
import { useT } from '../use-t';
enum DurationKeys {
Days = 'duration_days',
Hours = 'duration_hours',
Minutes = 'duration_minutes',
Seconds = 'duration_seconds',
DaysCompact = 'duration_days_compact',
HoursCompact = 'duration_hours_compact',
MinutesCompact = 'duration_minutes_compact',
SecondsCompact = 'duration_seconds_compact',
}
export const useDuration = (mode: 'normal' | 'compact' = 'normal') => {
const t = useT();
let DAYS = DurationKeys.Days;
let HOURS = DurationKeys.Hours;
let MINUTES = DurationKeys.Minutes;
let SECONDS = DurationKeys.Seconds;
if (mode === 'compact') {
DAYS = DurationKeys.DaysCompact;
HOURS = DurationKeys.HoursCompact;
MINUTES = DurationKeys.MinutesCompact;
SECONDS = DurationKeys.SecondsCompact;
}
return (durationInMilliseconds: number) => {
const duration = convertToDuration(durationInMilliseconds);
const segments = [];
if (duration.days > 0) {
segments.push(t(DAYS, { count: duration.days }));
}
if (duration.hours > 0) {
segments.push(t(HOURS, { count: duration.hours }));
}
if (duration.minutes > 0) {
segments.push(t(MINUTES, { count: duration.minutes }));
}
if (duration.seconds > 0) {
segments.push(t(SECONDS, { count: duration.seconds }));
}
if (segments.length > 0) {
return segments.join(' ');
}
return t(SECONDS, { count: 0 });
};
};

View File

@ -0,0 +1,3 @@
import { useTranslation } from 'react-i18next';
export const ns = 'react-helpers';
export const useT = () => useTranslation(ns).t;

View File

@ -1,6 +1,6 @@
import {
convertToCountdown,
convertToCountdownString,
convertToDuration,
getSecondsFromInterval,
} from './time';
@ -60,7 +60,8 @@ describe('convertToCountdown', () => {
[30, 3, 12, 3],
],
])('converts %d ms to %s', (time, countdown) => {
expect(convertToCountdown(time)).toEqual(countdown);
const duration = convertToDuration(time);
expect(Object.values(duration)).toEqual(countdown);
});
});

View File

@ -47,7 +47,14 @@ export function getSecondsFromInterval(str: string) {
return seconds;
}
export const convertToCountdown = (time: number) => {
type Duration = {
days: number;
hours: number;
minutes: number;
seconds: number;
};
export const convertToDuration = (time: number): Duration => {
const s = 1000;
const m = 1000 * 60;
const h = 1000 * 60 * 60;
@ -60,7 +67,7 @@ export const convertToCountdown = (time: number) => {
const minutes = Math.floor((t - days * d - hours * h) / m);
const seconds = Math.floor((t - days * d - hours * h - minutes * m) / s);
return [days, hours, minutes, seconds];
return { days, hours, minutes, seconds };
};
/**
@ -70,7 +77,7 @@ export const convertToCountdownString = (
time: number,
pattern = '0d00h00m00s'
) => {
const values = convertToCountdown(time);
const values = Object.values(convertToDuration(time));
let i = 0;
const countdown = pattern

View File

@ -5,6 +5,6 @@
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "../react-helpers/src/lib/use-duration.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}