diff --git a/.changelog/unreleased/715-live-candle-reconciliation.md b/.changelog/unreleased/715-live-candle-reconciliation.md new file mode 100644 index 00000000..c9851fa3 --- /dev/null +++ b/.changelog/unreleased/715-live-candle-reconciliation.md @@ -0,0 +1,8 @@ +--- +type: fixed +area: trade +pr: 715 +breaking: false +--- + +Live charts now reconcile streaming candles with historical data across interval boundaries, reconnects, and late updates without losing the user's viewport. diff --git a/.changelog/unreleased/727-source-driven-leverage.md b/.changelog/unreleased/727-source-driven-leverage.md new file mode 100644 index 00000000..9c8861ac --- /dev/null +++ b/.changelog/unreleased/727-source-driven-leverage.md @@ -0,0 +1,8 @@ +--- +type: fixed +area: trade +pr: 727 +breaking: false +--- + +Perpetual order leverage and margin estimates now use fresh market risk parameters, with unavailable liquidation estimates clearly marked instead of inferred. diff --git a/.changelog/unreleased/728-source-consistent-fee-preview.md b/.changelog/unreleased/728-source-consistent-fee-preview.md new file mode 100644 index 00000000..67a5ebd6 --- /dev/null +++ b/.changelog/unreleased/728-source-consistent-fee-preview.md @@ -0,0 +1,8 @@ +--- +type: fixed +area: trade +pr: 728 +breaking: false +--- + +Order previews now distinguish network and execution fees, use explicit depth and slippage status, and avoid presenting unknown liquidity or fee estimates as zero. diff --git a/.changelog/unreleased/729-execution-support-controls.md b/.changelog/unreleased/729-execution-support-controls.md new file mode 100644 index 00000000..7e85e984 --- /dev/null +++ b/.changelog/unreleased/729-execution-support-controls.md @@ -0,0 +1,8 @@ +--- +type: fixed +area: trade +pr: 729 +breaking: false +--- + +Unsupported reduce-only and attached take-profit or stop-loss controls are now gated before signing instead of appearing to create orders. diff --git a/apps/s03-indexer/src/mappings/mappingHandlers.ts b/apps/s03-indexer/src/mappings/mappingHandlers.ts index e34750a4..c8b32599 100644 --- a/apps/s03-indexer/src/mappings/mappingHandlers.ts +++ b/apps/s03-indexer/src/mappings/mappingHandlers.ts @@ -443,6 +443,9 @@ async function handleMarketCreated(event: DecodedEvent): Promise { marketId: market.id, key: market.key, version: parseOptionalNumber(fieldString(event, ["version"], 4)), + maxLeverage: serializeOptional( + event.values.named.max_leverage ?? event.values.named.maxLeverage, + ), rawConfig: JSON.stringify(event.values.named), ledger: event.ledger, timestamp: event.timestamp, diff --git a/apps/s03-indexer/tests/mappingHandlers.test.ts b/apps/s03-indexer/tests/mappingHandlers.test.ts index f23155ca..f2886b62 100644 --- a/apps/s03-indexer/tests/mappingHandlers.test.ts +++ b/apps/s03-indexer/tests/mappingHandlers.test.ts @@ -246,6 +246,7 @@ describe("SO4 event dispatch", () => { market: marketToken, creator: account, name: "TETH/TUSDC", + max_leverage: "25", }); await dispatchEvent(event); @@ -256,6 +257,8 @@ describe("SO4 event dispatch", () => { expect(market.marketTokenId).toBe(marketToken); expect(market.status).toBe("ACTIVE"); expect(market.createdBy).toBeDefined(); + const [snapshot] = records("MarketConfigSnapshot"); + expect(snapshot.maxLeverage).toBe("25"); }); test("indexes market creation with deterministic entity IDs", async () => { diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index b230fde6..1533fff4 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -17,6 +17,8 @@ import { import { cn } from "@workspace/ui/lib/utils" import { useOracleCandles } from "../../hooks/useOracleCandles" import { useLiveBar } from "../../hooks/useLiveBar" +import { canIncrementallyApply, mergeBars } from "../../lib/bar-reconciliation" +import type { LiveBarUpdate } from "../../lib/bar-reconciliation" import { usePositions } from "../../hooks/usePositions" import { buildCandleOptions, @@ -105,10 +107,25 @@ export function TVChartContainer({ symbol, period }: Props) { const chartRef = useRef(null) const seriesRef = useRef | null>(null) const priceLineRefs = useRef>(new Map()) + const displayedBarsRef = useRef>([]) + const hasRenderedHistoryRef = useRef(false) // True only after setData() has been called for the current symbol+period. // Prevents series.update() from firing against an empty or stale series. const hasDataRef = useRef(false) + const { data: candles = [], isLoading, isError } = useOracleCandles(symbol, period) + const liveValue = useLiveBar(symbol, period) as + | OhlcBar + | LiveBarUpdate + | null + // Keep the consumer tolerant of the old single-bar shape while callers + // migrate to the richer stream/backfill update contract. + const liveUpdate = liveValue && "current" in liveValue + ? liveValue + : liveValue + ? { current: liveValue, bars: [liveValue], source: "stream" as const } + : null + const liveBar = liveUpdate?.current ?? null const queryClient = useQueryClient() const { @@ -201,6 +218,8 @@ export function TVChartContainer({ symbol, period }: Props) { chartRef.current = null seriesRef.current = null priceLineRefs.current.clear() + displayedBarsRef.current = [] + hasRenderedHistoryRef.current = false } }, []) // mount once — symbol/period changes handled by separate effects below @@ -211,6 +230,14 @@ export function TVChartContainer({ symbol, period }: Props) { currentPeriodRef.current = period if (!seriesRef.current) return + seriesRef.current.setData([]) + hasDataRef.current = false + displayedBarsRef.current = [] + hasRenderedHistoryRef.current = false + // Remove all price lines — they belong to the previous symbol + priceLineRefs.current.forEach((pl) => seriesRef.current!.removePriceLine(pl)) + priceLineRefs.current.clear() + }, [symbol, period]) // On market switch, clear price lines immediately since they belong to the previous market if (symbolChanged) { @@ -230,6 +257,22 @@ export function TVChartContainer({ symbol, period }: Props) { // ── Load historical candles ──────────────────────────────────────────────── const fittedRef = useRef(false) useEffect(() => { + if (!seriesRef.current || candles.length === 0) return + const timeScale = chartRef.current?.timeScale() + const visibleRange = hasRenderedHistoryRef.current && timeScale && + typeof timeScale.getVisibleLogicalRange === "function" + ? timeScale.getVisibleLogicalRange() + : null + + displayedBarsRef.current = mergeBars([], candles) + seriesRef.current.setData(displayedBarsRef.current.map(toChartBar)) + hasDataRef.current = true + if (visibleRange && timeScale && typeof timeScale.setVisibleLogicalRange === "function") { + timeScale.setVisibleLogicalRange(visibleRange) + } else { + chartRef.current?.timeScale().fitContent() + } + hasRenderedHistoryRef.current = true if (!seriesRef.current) return if (candles.length === 0) { seriesRef.current.setData([]) @@ -250,13 +293,41 @@ export function TVChartContainer({ symbol, period }: Props) { // Only allowed after historical data is loaded (hasDataRef guards the race // where a live bar arrives before the first setData call completes). useEffect(() => { + if (!seriesRef.current || !liveUpdate || !hasDataRef.current) return + + const previous = displayedBarsRef.current + const updates = [...liveUpdate.bars].sort((a, b) => a.time - b.time) + + // lightweight-charts only accepts update() for the current or next point. + // Older arrivals are late corrections, and gaps indicate reconnect loss. + const canIncrementallyUpdate = canIncrementallyApply(previous, updates, period) + const merged = mergeBars(previous, updates) + + if (canIncrementallyUpdate) { + try { + for (const bar of updates) seriesRef.current.update(toChartBar(bar)) + displayedBarsRef.current = merged + return + } catch { + // Fall through to a preserving rehydrate if the chart rejected an edge case. + } + } + + const timeScale = chartRef.current?.timeScale() + const visibleRange = timeScale && typeof timeScale.getVisibleLogicalRange === "function" + ? timeScale.getVisibleLogicalRange() + : null + seriesRef.current.setData(merged.map(toChartBar)) + displayedBarsRef.current = merged + if (visibleRange && timeScale && typeof timeScale.setVisibleLogicalRange === "function") { + timeScale.setVisibleLogicalRange(visibleRange) if (!seriesRef.current || !liveBar || !hasDataRef.current.current) return try { seriesRef.current.update(toChartBar(liveBar)) } catch { // Live bar occasionally arrives out-of-order during rapid switching — safe to ignore } - }, [liveBar]) + }, [liveUpdate, period]) // ── Draw position entry + liquidation price lines ───────────────────────── useEffect(() => { diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx index 85ea4f04..1165322d 100644 --- a/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx +++ b/apps/web/src/features/trade/components/positions/CollateralDialog.test.tsx @@ -31,6 +31,15 @@ vi.mock("@/features/trade/hooks/useTokenPrices", () => ({ }), })) +vi.mock("../../hooks/useMarketRiskParams", () => ({ + useMarketRiskParams: () => ({ + params: { maxLeverage: 50, maintenanceMarginRateBps: null, updatedAt: Date.now() }, + state: "available", + isLoading: false, + isError: false, + }), +})) + const mockBalances: { data: Record | undefined } = { data: undefined } vi.mock("@/features/wallet/hooks/useTokenBalances", () => ({ diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx index 9c1cbd57..24e42e6f 100644 --- a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx +++ b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "@workspace/ui/components/button" import { Input } from "@workspace/ui/components/input" import { useTokenPrices } from "../../hooks/useTokenPrices" +import { useMarketRiskParams } from "../../hooks/useMarketRiskParams" import { createDecreaseOrder, createIncreaseOrder } from "../../lib/stellar" import { activeQueryNetwork, queryKeys } from "../../lib/query-keys" import type { Position } from "../../hooks/usePositions" @@ -34,6 +35,7 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr const account = useWalletStore((state) => state.address) const { data: balances } = useTokenBalances() const { getMidPrice } = useTokenPrices() + const marketRisk = useMarketRiskParams(position?.marketAddress ?? "") const queryClient = useQueryClient() // OB-089: Check whether the OrderVault is a real deployed contract. @@ -87,11 +89,18 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr if (amountNum >= position.collateralAmount) { isValid = false validationError = "Cannot remove all collateral (Close position instead)" - } else if (newLeverage > 50) { + } else if (marketRisk.state !== "available") { isValid = false - validationError = "New leverage exceeds maximum allowed (50x)" + validationError = "Risk parameters unavailable; try again when the market risk data is fresh" + } else if (newLeverage > (marketRisk.params?.maxLeverage ?? 0)) { + isValid = false + validationError = `New leverage exceeds maximum allowed (${marketRisk.params?.maxLeverage}x)` } } + if (isValid && marketRisk.state !== "available") { + isValid = false + validationError = "Risk parameters unavailable; try again when the market risk data is fresh" + } } async function handleConfirm() { @@ -247,6 +256,11 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr )} + {marketRisk.state !== "available" && ( +

+ Source risk parameters are unavailable or stale. +

+ )} {/* Validation & Error Messages */} diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx index 1a78d7fa..b9dcafed 100644 --- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.test.tsx @@ -49,6 +49,15 @@ vi.mock("../../hooks/useTradeFees", () => ({ }), })) +vi.mock("../../hooks/useMarketRiskParams", () => ({ + useMarketRiskParams: () => ({ + params: { maxLeverage: 50, maintenanceMarginRateBps: null, updatedAt: Date.now() }, + state: "available", + isLoading: false, + isError: false, + }), +})) + // ── Transaction layer: mocked so no real transaction is ever submitted ─────── vi.mock("../../lib/stellar", () => ({ createSwapOrder, sendBatchOrderTxn })) diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx index 0c047b68..8e697917 100644 --- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx @@ -31,6 +31,10 @@ import { import { useWalletStore } from "@/features/wallet/store/wallet-store" import { estimateFee } from "@/lib/soroban/simulate" import { formatAddress } from "@/shared/lib/format" +import { clampLeverage } from "../../lib/risk" +import { getProtectionPrice } from "../../lib/fee-preview" +import { useMarketRiskParams } from "../../hooks/useMarketRiskParams" +import { validateExecutionRequest } from "../../lib/execution-support" type Props = { open: boolean @@ -38,8 +42,8 @@ type Props = { tradeState: ReturnType sizeUsd: number entryPrice: number - liquidationPrice: number - totalFeesUsd: number + liquidationPrice: number | null + totalFeesUsd: number | null } export function ConfirmationDialog({ @@ -66,12 +70,17 @@ export function ConfirmationDialog({ sidecarOrders, clearSidecarOrders, } = tradeState + const marketRisk = useMarketRiskParams(tradeState.marketAddress) + const effectiveLeverage = marketRisk.params + ? clampLeverage(leverage, marketRisk.params.maxLeverage) + : 0 const fees = useTradeFees({ sizeUsd, marketAddress: tradeState.marketAddress, isIncrease: true, tradeType: tradeState.tradeType, + referencePrice: entryPrice, }) const priceImpactPct = getPriceImpactPct(sizeUsd, fees.priceImpactUsd) const estimatedEntryPrice = getEstimatedEntryPrice( @@ -79,10 +88,11 @@ export function ConfirmationDialog({ priceImpactPct, tradeFlags.isLong ) - const slippageFactor = tradeState.advanced.slippagePct / 100 - const acceptablePrice = tradeFlags.isLong - ? entryPrice * (1 + slippageFactor) - : entryPrice * (1 - slippageFactor) + const acceptablePrice = getProtectionPrice({ + referencePrice: entryPrice, + isLong: tradeFlags.isLong, + slippagePct: tradeState.advanced.slippagePct, + }) const { data: feeConfig } = useQuery({ queryKey: queryKeys.trade.feeConfig( @@ -95,9 +105,10 @@ export function ConfirmationDialog({ }) const maxPositionError = - !tradeFlags.isSwap && feeConfig && sizeUsd > feeConfig.maxPositionSizeUsd + !tradeFlags.isSwap && feeConfig?.source === "verified" && sizeUsd > feeConfig.maxPositionSizeUsd ? `Maximum position size for ${tradeState.toTokenAddress}/USD is $${feeConfig.maxPositionSizeUsd.toLocaleString()}.` : null + const executionValidation = validateExecutionRequest({ attachedOrders: sidecarOrders }) const sidecarCreateOrders = useMemo((): Array => { if (!account || sidecarOrders.length === 0) return [] @@ -109,7 +120,7 @@ export function ConfirmationDialog({ collateralDeltaAmount: Number(fromAmount || "0") * (order.sizePct / 100), sizeDeltaUsd: sizeUsd * (order.sizePct / 100), isLong: tradeFlags.isLong, - acceptablePrice: estimatedEntryPrice, + acceptablePrice: acceptablePrice ?? entryPrice, triggerPrice: Number(order.triggerPrice), orderType: order.type === "takeProfit" ? "LimitDecrease" : "StopLoss", receiveToken: collateralAddress!, @@ -123,6 +134,8 @@ export function ConfirmationDialog({ sizeUsd, tradeFlags.isLong, estimatedEntryPrice, + acceptablePrice, + entryPrice, ]) useEffect(() => { @@ -139,12 +152,12 @@ export function ConfirmationDialog({ collateralAmount: Number(fromAmount), sizeDeltaUsd: sizeUsd, isLong: tradeFlags.isLong, - acceptablePrice, + acceptablePrice: acceptablePrice ?? entryPrice, triggerPrice: tradeFlags.isMarket ? undefined - : Number(triggerPrice) || estimatedEntryPrice, + : Number(triggerPrice) || estimatedEntryPrice || acceptablePrice || entryPrice, orderType: tradeFlags.isMarket ? "MarketIncrease" : "LimitIncrease", - leverage, + leverage: effectiveLeverage, } const tx = sidecarCreateOrders.length @@ -185,14 +198,19 @@ export function ConfirmationDialog({ tradeFlags.isLong, tradeFlags.isMarket, triggerPrice, - leverage, + effectiveLeverage, estimatedEntryPrice, + acceptablePrice, + entryPrice, sidecarCreateOrders, ]) async function handleConfirm() { setIsSubmitting(true) try { + if (!executionValidation.valid) { + throw new Error(executionValidation.reason) + } if (tradeFlags.isSwap) { await createSwapOrder({ account: account ?? "GDUMMY...STELLAR", @@ -206,6 +224,9 @@ export function ConfirmationDialog({ if (!account) { throw new Error("Connect your wallet before placing an order.") } + if (marketRisk.state !== "available" || effectiveLeverage <= 0) { + throw new Error("Market risk parameters are unavailable or stale.") + } const storedReferralCode = readStoredReferralCode() if (storedReferralCode) { @@ -226,12 +247,12 @@ export function ConfirmationDialog({ collateralAmount: Number(fromAmount), sizeDeltaUsd: sizeUsd, isLong: tradeFlags.isLong, - acceptablePrice, + acceptablePrice: acceptablePrice ?? entryPrice, triggerPrice: tradeFlags.isMarket ? undefined - : Number(triggerPrice) || estimatedEntryPrice, + : Number(triggerPrice) || estimatedEntryPrice || acceptablePrice || entryPrice, orderType: tradeFlags.isMarket ? "MarketIncrease" : "LimitIncrease", - leverage, + leverage: effectiveLeverage, } await sendBatchOrderTxn(account, { @@ -268,33 +289,39 @@ export function ConfirmationDialog({ {maxPositionError && (

{maxPositionError}

)} - + 0 ? formatUsd(estimatedEntryPrice) : "-" + estimatedEntryPrice !== null && estimatedEntryPrice > 0 ? formatUsd(estimatedEntryPrice) : "Unavailable" } /> + {!tradeFlags.isMarket && ( + + )} 0.5} + value={priceImpactPct !== null ? `${priceImpactPct.toFixed(2)}%` : "Unavailable"} + highlight={priceImpactPct !== null && Math.abs(priceImpactPct) > 0.5} /> 0 ? formatUsd(liquidationPrice) : "-"} + label="Liquidation estimate" + value={liquidationPrice !== null && liquidationPrice > 0 ? formatUsd(liquidationPrice) : "Unavailable"} /> - + {estimateError && (

Fee estimation warning: {estimateError} @@ -314,6 +341,11 @@ export function ConfirmationDialog({ {order.triggerPrice} ({order.sizePct}%)

))} + {!executionValidation.valid && ( +

+ {executionValidation.reason} +

+ )} )} @@ -323,7 +355,7 @@ export function ConfirmationDialog({ value={`${fromAmount || "0"} ${formatAddress(collateralAddress!)}`} />
- +
@@ -336,7 +368,8 @@ export function ConfirmationDialog({ disabled={ isSubmitting || sizeUsd <= 0 || - !!maxPositionError + !!maxPositionError || + !executionValidation.valid } className={ tradeFlags.isLong diff --git a/apps/web/src/features/trade/components/trade-panel/TradeInfoRows.tsx b/apps/web/src/features/trade/components/trade-panel/TradeInfoRows.tsx index f37ea90d..d6dae569 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradeInfoRows.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradeInfoRows.tsx @@ -3,17 +3,20 @@ import { Numeric } from "@workspace/ui/components/numeric" import { useTradeFees } from "../../hooks/useTradeFees" import { useFundingRate } from "../../hooks/useFundingRate" import { useTokenPrices } from "../../hooks/useTokenPrices" -import { estimateLiquidationPrice } from "../../lib/trade-math" import { getEstimatedEntryPrice, getPriceImpactPct } from "../../lib/pricing" +import { estimatePositionRisk } from "../../lib/risk" import { FundingRate } from "../FundingRate" import type { ReactNode } from "react" import type { TradeState } from "../../hooks/useTradeState" +import type { ExistingExposure, MarketRiskParams } from "../../lib/risk" type Props = Pick< TradeState, "tradeType" | "toTokenAddress" | "marketAddress" | "leverage" | "fromAmount" | "tradeMode" > & { sizeUsd: number + riskParams: MarketRiskParams | null + existingExposure?: ExistingExposure } export function TradeInfoRows({ @@ -23,36 +26,39 @@ export function TradeInfoRows({ leverage, sizeUsd, tradeMode, + riskParams, + existingExposure, }: Props) { const { getMidPrice } = useTokenPrices() const { data: fundingRate } = useFundingRate(marketAddress) - const fees = useTradeFees({ sizeUsd, marketAddress, isIncrease: true, tradeType }) + const fees = useTradeFees({ sizeUsd, marketAddress, isIncrease: true, tradeType, referencePrice: getMidPrice(toTokenAddress) }) const isLong = tradeType === "Long" const entryPrice = getMidPrice(toTokenAddress) const priceImpactPct = getPriceImpactPct(sizeUsd, fees.priceImpactUsd) const estimatedEntryPrice = getEstimatedEntryPrice(entryPrice, priceImpactPct, isLong) - const liquidationPrice = - sizeUsd > 0 && estimatedEntryPrice > 0 - ? estimateLiquidationPrice({ - entryPrice: estimatedEntryPrice, - collateralUsd: sizeUsd / leverage, - sizeUsd, - isLong, - }) - : 0 + const positionEstimate = riskParams && sizeUsd > 0 && estimatedEntryPrice !== null && estimatedEntryPrice > 0 + ? estimatePositionRisk({ + entryPrice: estimatedEntryPrice, + collateralUsd: leverage > 0 ? sizeUsd / leverage : 0, + leverage, + isLong, + risk: riskParams, + existing: existingExposure, + }) + : null - const executionFeeDisplay = fees.executionFeeXlm > 0 + const executionFeeDisplay = typeof fees.executionFeeXlm === "number" && typeof fees.executionFeeUsd === "number" ? <>~{fees.executionFeeXlm.toFixed(2)} XLM () - : "-" + : "Unavailable" if (tradeType === "Swap") { return (
- } /> - } /> + : "Unavailable"} /> + : "Unavailable"} />
) @@ -60,9 +66,16 @@ export function TradeInfoRows({ return (
- 0 ? : "-"} /> - {tradeMode === "Limit" && } - 0 ? : "-"} /> + 0 ? : "Unavailable"} /> + {tradeMode === "Limit" && } + : "Unavailable"} /> + : "Unavailable"} /> + 0 + ? + : "Unavailable"} + /> - } /> - 0.5 ? "danger" : "neutral"} />} /> + : "Unavailable"} /> + 0.5 ? "danger" : "neutral"} /> : "Unavailable"} />
- } bold /> + : "Unavailable"} bold />
) diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx index 3c84afb6..76f9b3c2 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.test.tsx @@ -50,6 +50,14 @@ vi.mock("../../hooks/useTradeFees", () => ({ feesBreakdown: [], }), })) +vi.mock("../../hooks/useMarketRiskParams", () => ({ + useMarketRiskParams: () => ({ + params: { maxLeverage: 50, maintenanceMarginRateBps: null, updatedAt: Date.now() }, + state: "available", + isLoading: false, + isError: false, + }), +})) // ── Wallet balances: USDC balance of 500 (the default collateral token) ────── // Hoisted because the `vi.mock` factory below closes over it. diff --git a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx index 4fadc715..b2215354 100644 --- a/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx +++ b/apps/web/src/features/trade/components/trade-panel/TradePanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useState } from "react" import { useMemo, useState, useEffect } from "react" import { Tabs, @@ -12,13 +13,12 @@ import { Numeric } from "@workspace/ui/components/numeric" import { toast } from "@workspace/ui/components/toast" import { useTokenPrices } from "../../hooks/useTokenPrices" import { useTradeFees } from "../../hooks/useTradeFees" +import { useMarketRiskParams } from "../../hooks/useMarketRiskParams" +import { usePositions } from "../../hooks/usePositions" import { useTokenBalances } from "../../../wallet/hooks/useTokenBalances" // NB: useTokenBalances is also consumed here (not only in TradeInputs) to // validate the entered amount against the wallet balance before submit. -import { - estimateLiquidationPrice, - sizeFromCollateralAndLeverage, -} from "../../lib/trade-math" +import { sizeFromCollateralAndLeverage } from "../../lib/trade-math" import { getToken } from "../../data/tokens" import { TradeInfoRows } from "./TradeInfoRows" import { ConfirmationDialog } from "./ConfirmationDialog" @@ -29,11 +29,13 @@ import { NumberInput } from "@/shared/components/NumberInput" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { TokenIcon } from "@/shared/components/TokenIcon" import { formatAddress } from "@/shared/lib/format" +import { clampLeverage, estimatePositionRisk } from "../../lib/risk" import { validateAmount, validatePrice, validateBalance, } from "@/lib/input-validation" +import { EXECUTION_SUPPORT } from "../../lib/execution-support" type TradeController = ReturnType @@ -65,6 +67,22 @@ export function TradePanel({ trade }: TradePanelProps) { advanced, } = trade + const marketRisk = useMarketRiskParams(marketAddress) + const { data: positions = [] } = usePositions() + const existingExposure = positions.find( + (position) => position.marketAddress === marketAddress && position.isLong === tradeFlags.isLong, + ) + const maxLeverage = marketRisk.params?.maxLeverage ?? 1 + const boundedLeverage = marketRisk.params + ? clampLeverage(leverage, maxLeverage) + : 0 + + useEffect(() => { + if (marketRisk.state === "available" && leverage > maxLeverage) { + setLeverage(maxLeverage) + } + }, [leverage, maxLeverage, marketRisk.state, setLeverage]) + const entryPrice = getMidPrice(toTokenAddress) // Collateral is configured per market and side, so it can be unset; an // unknown key prices at 0, which is the right "no collateral selected" value. @@ -72,7 +90,7 @@ export function TradePanel({ trade }: TradePanelProps) { parseFloat(fromAmount || "0") * getMidPrice(collateralAddress ?? "") const sizeUsd = tradeFlags.isSwap ? collateralUsd - : sizeFromCollateralAndLeverage(collateralUsd, leverage) + : sizeFromCollateralAndLeverage(collateralUsd, boundedLeverage) const fees = useTradeFees({ sizeUsd, @@ -81,19 +99,22 @@ export function TradePanel({ trade }: TradePanelProps) { tradeType, }) - const liquidationPrice = useMemo(() => { - if (!tradeFlags.isPosition || sizeUsd <= 0 || entryPrice <= 0) return 0 - return estimateLiquidationPrice({ - entryPrice, + const positionEstimate = useMemo(() => { + if (!tradeFlags.isPosition || !marketRisk.params) return null + return estimatePositionRisk({ collateralUsd, - sizeUsd, + leverage: boundedLeverage, + entryPrice, isLong: tradeFlags.isLong, + risk: marketRisk.params, + existing: existingExposure, }) - }, [tradeFlags, sizeUsd, entryPrice, collateralUsd]) + }, [boundedLeverage, collateralUsd, entryPrice, existingExposure, marketRisk.params, tradeFlags]) const priceStale = isStale(toTokenAddress) const toTokenLabel = formatTokenLabel(toTokenAddress) - const canTrade = Boolean(account) && sizeUsd > 0 && !priceStale + const canTrade = Boolean(account) && sizeUsd > 0 && !priceStale && + (tradeFlags.isSwap || marketRisk.state === "available") // Detect mode changes and notify user about discarded fields useEffect(() => { @@ -203,6 +224,17 @@ export function TradePanel({ trade }: TradePanelProps) { {/* ── Leverage slider (positions only) ─────────────────────── */} {tradeFlags.isPosition && ( + marketRisk.state === "available" ? ( + + ) : ( +

+ Leverage unavailable until current market risk parameters are available. +

+ )
@@ -211,7 +243,7 @@ export function TradePanel({ trade }: TradePanelProps) { {/* ── Size summary ─────────────────────────────────────────── */} {tradeFlags.isPosition && sizeUsd > 0 && (
- Position size + Position size estimate
)} @@ -227,6 +259,8 @@ export function TradePanel({ trade }: TradePanelProps) { leverage={leverage} fromAmount={fromAmount} sizeUsd={sizeUsd} + riskParams={marketRisk.params} + existingExposure={existingExposure} /> {/* ── Advanced options ──────────────────────────────────────── */} @@ -261,6 +295,11 @@ export function TradePanel({ trade }: TradePanelProps) {

This sets the maximum allowable slippage for the order. Orders will revert if the fill price exceeds this threshold.

+ {!EXECUTION_SUPPORT.attachedTriggers && ( +

+ Take-profit and stop-loss attachments are unavailable for this execution venue. +

+ )} )} @@ -300,7 +339,7 @@ export function TradePanel({ trade }: TradePanelProps) { tradeState={trade} sizeUsd={sizeUsd} entryPrice={entryPrice} - liquidationPrice={liquidationPrice} + liquidationPrice={positionEstimate?.liquidationPrice ?? null} totalFeesUsd={fees.totalFeesUsd} /> diff --git a/apps/web/src/features/trade/hooks/useLiveBar.ts b/apps/web/src/features/trade/hooks/useLiveBar.ts index 991941d0..b5a1a2c9 100644 --- a/apps/web/src/features/trade/hooks/useLiveBar.ts +++ b/apps/web/src/features/trade/hooks/useLiveBar.ts @@ -1,3 +1,23 @@ +import { useEffect, useRef, useState } from "react" +import { BINANCE_PERIOD, BINANCE_SYMBOL, fetchOracleCandles } from "../lib/oracle" +import { parseStreamBar } from "../lib/bar-reconciliation" +import type { LiveBarUpdate } from "../lib/bar-reconciliation" + +type BinanceKlineMsg = { + e: "kline" + k: { + t: number // kline open time (ms) + o: string + h: string + l: string + c: string + v: string + } +} + +const BINANCE_WS = "wss://stream.binance.com:9443/ws" +const POLL_MS = 1500 +const RECONNECT_MS = 2000 import { useEffect, useState } from "react" import type { OhlcBar } from "../lib/oracle" import { marketSubscriptionManager } from "../lib/market-data-stream" @@ -8,6 +28,14 @@ import { marketSubscriptionManager } from "../lib/market-data-stream" * Consumes from the centralized marketSubscriptionManager so connection, * polling fallback, and timer resources are shared across chart and other consumers. */ +export function useLiveBar(symbol: string | undefined, period: string): LiveBarUpdate | null { + const [liveBar, setLiveBar] = useState(null) + + // These refs are fine to share — they hold the *current* WS handle and poll timer + // so cleanup can reach them from the returned teardown function. + const wsRef = useRef(null) + const pollTimerRef = useRef | null>(null) + const isHiddenRef = useRef(false) export function useLiveBar(symbol: string | undefined, period: string): OhlcBar | null { const [liveBar, setLiveBar] = useState(() => { if (!symbol) return null @@ -15,6 +43,126 @@ export function useLiveBar(symbol: string | undefined, period: string): OhlcBar }) useEffect(() => { + // ── Per-instance mounted flag ───────────────────────────────────────── + // A plain local `let`, NOT a ref. Each effect invocation gets its own + // copy captured by closure, so the old effect's callbacks can never see + // `mounted = true` after cleanup even if the new effect has already started. + let mounted = true + let usingPoll = false + let gotFirstWsMessage = false + let reconnectTimer: ReturnType | null = null + let firstMsgTimeout: ReturnType | null = null + let hasConnected = false + + setLiveBar(null) + + if (!symbol) return () => { mounted = false } + + const binanceSym = BINANCE_SYMBOL[symbol] + const binancePeriod = BINANCE_PERIOD[period] + + // ── Polling fallback ──────────────────────────────────────────────────── + function startPolling(backfill = false) { + if (usingPoll) return + usingPoll = true + let firstPoll = true + + async function tick() { + if (!mounted) return + if (!isHiddenRef.current) { + try { + const limit = firstPoll && backfill ? 3 : 1 + const bars = await fetchOracleCandles(symbol!, period, limit) + if (bars.length > 0) { + setLiveBar({ + current: bars[bars.length - 1], + bars, + source: firstPoll && backfill ? "backfill" : "poll", + }) + } + firstPoll = false + const res = await fetchOracleCandles(symbol!, period, 1) + if (res.candles.length > 0) setLiveBar(res.candles[res.candles.length - 1]) + } catch { /* silent retry */ } + } + pollTimerRef.current = setTimeout(tick, POLL_MS) + } + + pollTimerRef.current = setTimeout(tick, POLL_MS) + } + + function stopPolling() { + usingPoll = false + if (pollTimerRef.current) { clearTimeout(pollTimerRef.current); pollTimerRef.current = null } + } + + // ── WebSocket ────────────────────────────────────────────────────────── + function connect() { + if (!binanceSym || !binancePeriod || isHiddenRef.current) { startPolling(true); return } + + const url = `${BINANCE_WS}/${binanceSym.toLowerCase()}@kline_${binancePeriod}` + const ws = new WebSocket(url) + wsRef.current = ws + + // If no message arrives within 4 s, fall back to polling permanently + firstMsgTimeout = setTimeout(() => { + if (!gotFirstWsMessage && mounted) { ws.close(); startPolling(true) } + }, 4000) + + ws.onmessage = (evt: MessageEvent) => { + if (!mounted) return + if (!gotFirstWsMessage) { + gotFirstWsMessage = true + if (firstMsgTimeout) { clearTimeout(firstMsgTimeout); firstMsgTimeout = null } + stopPolling() + } + if (isHiddenRef.current) return + try { + const msg = JSON.parse(evt.data as string) as BinanceKlineMsg + const bar = parseStreamBar(msg.k, period) + if (bar) setLiveBar({ current: bar, bars: [bar], source: "stream" }) + } catch { /* malformed frame */ } + } + + ws.onopen = () => { + if (!hasConnected) { + hasConnected = true + return + } + // Reconnects may miss one or more interval boundaries. Fetch a small + // overlap from the selected historical source before applying stream data. + void fetchOracleCandles(symbol!, period, 3).then((bars) => { + if (mounted && bars.length > 0) { + setLiveBar({ current: bars[bars.length - 1], bars, source: "backfill" }) + } + }).catch(() => undefined) + } + + ws.onclose = () => { + if (firstMsgTimeout) { clearTimeout(firstMsgTimeout); firstMsgTimeout = null } + if (!mounted) return + if (!gotFirstWsMessage) { + startPolling(true) + } else { + gotFirstWsMessage = false + if (!isHiddenRef.current) reconnectTimer = setTimeout(connect, RECONNECT_MS) + } + } + + ws.onerror = () => ws.close() + } + + // ── Tab visibility ──────────────────────────────────────────────────── + function handleVisibility() { + isHiddenRef.current = document.visibilityState === "hidden" + if (isHiddenRef.current) { + wsRef.current?.close(); wsRef.current = null + stopPolling() + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } + } else { + usingPoll = false; gotFirstWsMessage = false + connect() + } if (!symbol) { setLiveBar(null) return diff --git a/apps/web/src/features/trade/hooks/useMarketRiskParams.ts b/apps/web/src/features/trade/hooks/useMarketRiskParams.ts new file mode 100644 index 00000000..cf6424a9 --- /dev/null +++ b/apps/web/src/features/trade/hooks/useMarketRiskParams.ts @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query" +import { executeGraphQLQuery } from "@/lib/graphql/client" +import { GET_MARKET_RISK_PARAMS } from "@/lib/graphql/queries" +import { indexerQueryKeys } from "@/lib/graphql/query-keys" +import { getRiskState } from "../lib/risk" +import type { MarketRiskParams, RiskState } from "../lib/risk" + +function parsePositiveNumber(value: string | null | undefined): number | null { + if (!value) return null + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function parseTimestamp(value: Date | string | null | undefined): number { + if (!value) return NaN + const timestamp = new Date(value).getTime() + return Number.isFinite(timestamp) ? timestamp : NaN +} + +export type MarketRiskResult = { + params: MarketRiskParams | null + state: RiskState + isLoading: boolean + isError: boolean +} + +export function useMarketRiskParams(marketKey: string): MarketRiskResult { + const query = useQuery({ + queryKey: indexerQueryKeys.markets.risk(marketKey), + queryFn: async (): Promise => { + const result = await executeGraphQLQuery(GET_MARKET_RISK_PARAMS, { marketKey }) + const market = result.markets.nodes[0] + const snapshot = market?.latestConfigSnapshot + const maxLeverage = parsePositiveNumber(snapshot?.maxLeverage) + if (market?.status !== "ACTIVE" || !snapshot || maxLeverage === null) { + throw new Error("Market risk parameters are unavailable") + } + + return { + maxLeverage, + // The current indexer source does not expose maintenance margin yet; + // liquidation stays unavailable until that parameter is verified. + maintenanceMarginRateBps: null, + updatedAt: parseTimestamp(snapshot.timestamp), + } + }, + enabled: Boolean(marketKey), + staleTime: 60_000, + refetchOnWindowFocus: true, + retry: false, + }) + + const params = query.data ?? null + return { + params, + state: getRiskState(params), + isLoading: query.isLoading, + isError: query.isError, + } +} diff --git a/apps/web/src/features/trade/hooks/useMarketsInfo.ts b/apps/web/src/features/trade/hooks/useMarketsInfo.ts index 0fd904e8..7afa9b4f 100644 --- a/apps/web/src/features/trade/hooks/useMarketsInfo.ts +++ b/apps/web/src/features/trade/hooks/useMarketsInfo.ts @@ -13,7 +13,7 @@ export type MarketInfo = Market & { availableLiquidityShort: number borrowingRatePerHour: number fundingRatePerHour: number - maxLeverage: number + maxLeverage: number | null isDisabled: boolean } @@ -74,7 +74,7 @@ async function fetchMarketsInfo(markets: Array): Promise): Promise + positionFeeUsd: number | null + priceImpactUsd: number | null + executionFeeUsd: number | null + executionFeeXlm: number | null + totalFeesUsd: number | null + status: "estimated" | "unknown" | "stale" | "insufficient" + feesBreakdown: Array<{ label: string; valueUsd: number | null }> } const CHAIN_ID = activeQueryNetwork() +const FEE_CONFIG_MAX_AGE_MS = 120_000 export function useTradeFees(params: { sizeUsd: number marketAddress: string isIncrease: boolean tradeType?: "Long" | "Short" | "Swap" + depth?: VerifiedDepth | null + referencePrice?: number }): TradeFees { const { sizeUsd, marketAddress, tradeType = "Long" } = params @@ -36,11 +39,36 @@ export function useTradeFees(params: { if (!sizeUsd || sizeUsd <= 0) { return { - positionFeeUsd: 0, - priceImpactUsd: 0, - executionFeeUsd: 0, - executionFeeXlm: 0, - totalFeesUsd: 0, + positionFeeUsd: null, + priceImpactUsd: null, + executionFeeUsd: null, + executionFeeXlm: null, + totalFeesUsd: null, + status: "unknown", + feesBreakdown: [], + } + } + + if (!feeConfig || feeConfig.source !== "verified") { + return { + positionFeeUsd: null, + priceImpactUsd: null, + executionFeeUsd: null, + executionFeeXlm: null, + totalFeesUsd: null, + status: "unknown", + feesBreakdown: [], + } + } + + if (!Number.isFinite(feeConfig.updatedAt) || Date.now() - feeConfig.updatedAt > FEE_CONFIG_MAX_AGE_MS) { + return { + positionFeeUsd: null, + priceImpactUsd: null, + executionFeeUsd: null, + executionFeeXlm: null, + totalFeesUsd: null, + status: "stale", feesBreakdown: [], } } @@ -51,12 +79,20 @@ export function useTradeFees(params: { : (feeConfig?.positionFeeBps ?? 10) const executionFeeXlm = feeConfig?.minExecutionFeeXlm ?? 0.3 - const executionFeeUsd = executionFeeXlm * (xlmPrice || 0.17) + const executionFeeUsd = xlmPrice > 0 ? executionFeeXlm * xlmPrice : null const positionFeeUsd = (sizeUsd * feeBps) / 10_000 - const priceImpactUsd = -(sizeUsd * PRICE_IMPACT_BPS) / 10_000 + const depthImpact = calculateDepthImpact({ + side: tradeType === "Short" ? "short" : "long", + orderValueUsd: sizeUsd, + referencePrice: params.referencePrice ?? 0, + depth: params.depth, + }) + const priceImpactUsd = depthImpact.impactPct === null ? null : (sizeUsd * depthImpact.impactPct) / 100 - const totalFeesUsd = positionFeeUsd + Math.abs(priceImpactUsd) + executionFeeUsd + const totalFeesUsd = priceImpactUsd === null || executionFeeUsd === null + ? null + : positionFeeUsd + priceImpactUsd + executionFeeUsd return { positionFeeUsd, @@ -64,6 +100,7 @@ export function useTradeFees(params: { executionFeeUsd, executionFeeXlm, totalFeesUsd, + status: depthImpact.status, feesBreakdown: [ { label: tradeType === "Swap" ? "Swap fee" : "Position fee", valueUsd: positionFeeUsd }, { label: "Price impact", valueUsd: priceImpactUsd }, diff --git a/apps/web/src/features/trade/hooks/useTradeState.ts b/apps/web/src/features/trade/hooks/useTradeState.ts index e05a93c7..fc8784bb 100644 --- a/apps/web/src/features/trade/hooks/useTradeState.ts +++ b/apps/web/src/features/trade/hooks/useTradeState.ts @@ -15,6 +15,7 @@ import { useWalletStore } from "@/features/wallet/store/wallet-store" import { CONTRACTS } from "@/app/config/contracts" import { POOL_MARKETS } from "@/features/pools/data/markets" import { ENV } from "@/app/config/env" +import { EXECUTION_SUPPORT } from "../lib/execution-support" export type TradeType = "Long" | "Short" | "Swap" export type TradeMode = "Market" | "Limit" | "Trigger" @@ -50,7 +51,7 @@ export type TradeState = { // Input amounts (raw string so user can type freely) fromAmount: string toAmount: string // size input for Long/Short, receive amount for Swap - // Leverage (1x – 50x for Long/Short) + // Leverage is clamped to the selected market's source risk parameters. leverage: number // Trigger price (Limit / Stop-Loss orders) triggerPrice: string @@ -142,6 +143,11 @@ function normalizeTradeState(state: TradeState): TradeState { short: market.shortToken, }, }, + sidecarOrders: EXECUTION_SUPPORT.attachedTriggers ? state.sidecarOrders : [], + advanced: { + ...state.advanced, + limitOrTPSL: EXECUTION_SUPPORT.attachedTriggers && state.advanced.limitOrTPSL, + }, } } @@ -155,6 +161,10 @@ function normalizeTradeState(state: TradeState): TradeState { fromAmount: "", triggerPrice: "", sidecarOrders: [], + advanced: { + ...state.advanced, + limitOrTPSL: false, + }, } } @@ -313,7 +323,10 @@ export function useTradeState() { // TP/SL sidecar order setters // TODO: wire into createIncreaseOrder — pass sidecarOrders as attached decrease orders const addSidecarOrder = useCallback( - (order: SidecarOrder) => update({ sidecarOrders: [...state.sidecarOrders, order] }), + (order: SidecarOrder) => { + if (!EXECUTION_SUPPORT.attachedTriggers) return + update({ sidecarOrders: [...state.sidecarOrders, order] }) + }, [state.sidecarOrders, update], ) const removeSidecarOrder = useCallback( @@ -344,7 +357,15 @@ export function useTradeState() { setLeverage: (leverage: number) => update({ leverage }), setTriggerPrice: (triggerPrice: string) => update({ triggerPrice }), setAdvanced: (advanced: Partial) => - update({ advanced: { ...state.advanced, ...advanced } }), + update({ + advanced: { + ...state.advanced, + ...advanced, + limitOrTPSL: EXECUTION_SUPPORT.attachedTriggers + ? (advanced.limitOrTPSL ?? state.advanced.limitOrTPSL) + : false, + }, + }), setSlippagePct: (slippagePct: number) => update({ advanced: { ...state.advanced, slippagePct } }), switchTokens, diff --git a/apps/web/src/features/trade/lib/bar-reconciliation.test.ts b/apps/web/src/features/trade/lib/bar-reconciliation.test.ts new file mode 100644 index 00000000..8f6bffb3 --- /dev/null +++ b/apps/web/src/features/trade/lib/bar-reconciliation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" +import { canIncrementallyApply, candleTime, mergeBars, parseStreamBar } from "./bar-reconciliation" + +const bar = (time: number, close = 100) => ({ + time, + open: 99, + high: Math.max(101, close), + low: 98, + close, +}) + +describe("bar reconciliation", () => { + it("normalizes events at both sides of an interval boundary", () => { + expect(candleTime(299, "5m")).toBe(0) + expect(candleTime(300, "5m")).toBe(300) + expect(candleTime(599, "5m")).toBe(300) + }) + + it("keeps the newest duplicate and sorts out-of-order updates", () => { + expect(mergeBars( + [bar(300), bar(600, 110)], + [bar(300, 105), bar(0, 95), bar(300, 106)], + )).toEqual([bar(0, 95), bar(300, 106), bar(600, 110)]) + }) + + it("treats a late event as a correction without dropping neighboring bars", () => { + const corrected = mergeBars( + [bar(0, 100), bar(300, 110), bar(600, 120)], + [bar(300, 115)], + ) + expect(corrected.map((item) => item.time)).toEqual([0, 300, 600]) + expect(corrected[1]?.close).toBe(115) + }) + + it("allows current updates and contiguous appends, including duplicate frames", () => { + expect(canIncrementallyApply([bar(0), bar(300)], [bar(300, 105)], "5m")).toBe(true) + expect(canIncrementallyApply([bar(0), bar(300)], [bar(600), bar(600, 101)], "5m")).toBe(true) + expect(canIncrementallyApply([bar(0), bar(300)], [bar(900)], "5m")).toBe(false) + }) + + it("merges history and stream overlap with complete OHLCV values", () => { + const streamBar = parseStreamBar( + { t: 300_999, o: "100", h: "125", l: "95", c: "120", v: "42" }, + "5m", + ) + expect(streamBar).toMatchObject({ time: 300, open: 100, high: 125, low: 95, close: 120, volume: 42 }) + expect(mergeBars([bar(300, 110)], streamBar ? [streamBar] : [])).toEqual([streamBar]) + }) + + it("rejects malformed stream values", () => { + expect(parseStreamBar({ t: 300_000, o: "bad", h: "1", l: "1", c: "1" }, "5m")).toBeNull() + }) +}) diff --git a/apps/web/src/features/trade/lib/bar-reconciliation.ts b/apps/web/src/features/trade/lib/bar-reconciliation.ts new file mode 100644 index 00000000..f720cab4 --- /dev/null +++ b/apps/web/src/features/trade/lib/bar-reconciliation.ts @@ -0,0 +1,79 @@ +import type { OhlcBar } from "./oracle" + +export type LiveBarUpdate = { + current: OhlcBar + bars: Array + source: "stream" | "poll" | "backfill" +} + +export const PERIOD_SECONDS: Record = { + "1m": 60, + "5m": 5 * 60, + "15m": 15 * 60, + "1h": 60 * 60, + "4h": 4 * 60 * 60, + "1D": 24 * 60 * 60, +} + +/** Maps any event timestamp into the opening timestamp of its candle. */ +export function candleTime(timestampSeconds: number, period: string): number { + const interval = PERIOD_SECONDS[period] + if (!interval || !Number.isFinite(timestampSeconds)) return timestampSeconds + return Math.floor(timestampSeconds / interval) * interval +} + +/** + * History and streams can overlap, and reconnects can deliver older events. + * Keep one canonical bar per opening time; the newest observation wins. + */ +export function mergeBars( + history: Array, + updates: Array, +): Array { + const byTime = new Map() + for (const bar of history) byTime.set(bar.time, bar) + for (const bar of updates) byTime.set(bar.time, bar) + return [...byTime.values()].sort((a, b) => a.time - b.time) +} + +export function canIncrementallyApply( + previous: Array, + updates: Array, + period: string, +): boolean { + const lastTime = previous.at(-1)?.time + if (lastTime === undefined) return false + if (updates.some((bar) => bar.time < lastTime)) return false + + const interval = PERIOD_SECONDS[period] + if (interval === undefined) return true + + let expected = lastTime + for (const time of [...new Set(updates.map((bar) => bar.time))].sort((a, b) => a - b)) { + if (time <= lastTime) continue + if (time !== expected + interval) return false + expected = time + } + return true +} + +export function parseStreamBar( + raw: { t: number; o: string; h: string; l: string; c: string; v?: string }, + period: string, +): OhlcBar | null { + const values = [Number(raw.o), Number(raw.h), Number(raw.l), Number(raw.c)] + const volume = raw.v === undefined ? undefined : Number(raw.v) + if ( + !Number.isFinite(raw.t) || + values.some((value) => !Number.isFinite(value)) || + (volume !== undefined && !Number.isFinite(volume)) + ) return null + return { + time: candleTime(Math.floor(raw.t / 1000), period), + open: values[0], + high: values[1], + low: values[2], + close: values[3], + ...(volume === undefined ? {} : { volume }), + } +} diff --git a/apps/web/src/features/trade/lib/data-store.ts b/apps/web/src/features/trade/lib/data-store.ts index dd7eab91..c7f5d6f4 100644 --- a/apps/web/src/features/trade/lib/data-store.ts +++ b/apps/web/src/features/trade/lib/data-store.ts @@ -11,6 +11,8 @@ export type FeeConfig = { borrowingRatePerHour: number minExecutionFeeXlm: number maxPositionSizeUsd: number + source: "verified" | "unavailable" + updatedAt: number } export const DEFAULT_FEE_CONFIG: FeeConfig = { @@ -19,6 +21,8 @@ export const DEFAULT_FEE_CONFIG: FeeConfig = { borrowingRatePerHour: 0.0001, minExecutionFeeXlm: 0.3, maxPositionSizeUsd: 5_000_000, + source: "unavailable", + updatedAt: Number.NaN, } export type CircuitBreakerStatus = { diff --git a/apps/web/src/features/trade/lib/execution-support.test.ts b/apps/web/src/features/trade/lib/execution-support.test.ts new file mode 100644 index 00000000..8e02bbfa --- /dev/null +++ b/apps/web/src/features/trade/lib/execution-support.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" +import { validateExecutionRequest } from "./execution-support" + +describe("execution support gates", () => { + it("allows an ordinary order with no unsupported controls", () => { + expect(validateExecutionRequest({})).toEqual({ valid: true }) + }) + + it("rejects reduce-only before signing when the gateway does not enforce it", () => { + const result = validateExecutionRequest({ reduceOnly: true }) + expect(result.valid).toBe(false) + if (!result.valid) expect(result.reason).toMatch(/reduce-only/i) + }) + + it("rejects attached TP/SL orders instead of claiming they were created", () => { + const result = validateExecutionRequest({ + attachedOrders: [{ type: "takeProfit", triggerPrice: "105", sizePct: 100 }], + }) + expect(result.valid).toBe(false) + if (!result.valid) expect(result.reason).toMatch(/take-profit/i) + }) +}) diff --git a/apps/web/src/features/trade/lib/execution-support.ts b/apps/web/src/features/trade/lib/execution-support.ts new file mode 100644 index 00000000..8ddde9b3 --- /dev/null +++ b/apps/web/src/features/trade/lib/execution-support.ts @@ -0,0 +1,42 @@ +export type AttachedOrder = { + type: "takeProfit" | "stopLoss" + triggerPrice: string + sizePct: number +} + +/** + * Capabilities are deliberately conservative until the gateway exposes and + * verifies these semantics. Decrease order types are supported directly by + * the venue; attached orders and a separate reduce-only flag are not. + */ +export const EXECUTION_SUPPORT = { + reduceOnly: false, + attachedTriggers: false, +} as const + +export type ExecutionRequest = { + reduceOnly?: boolean + attachedOrders?: ReadonlyArray +} + +export type ExecutionValidation = + | { valid: true } + | { valid: false; reason: string } + +export function validateExecutionRequest(request: ExecutionRequest): ExecutionValidation { + if (request.reduceOnly && !EXECUTION_SUPPORT.reduceOnly) { + return { + valid: false, + reason: "Reduce-only orders are unavailable until the execution gateway enforces them.", + } + } + + if ((request.attachedOrders?.length ?? 0) > 0 && !EXECUTION_SUPPORT.attachedTriggers) { + return { + valid: false, + reason: "Take-profit and stop-loss attachments are unavailable until the execution gateway supports them.", + } + } + + return { valid: true } +} diff --git a/apps/web/src/features/trade/lib/fee-preview.test.ts b/apps/web/src/features/trade/lib/fee-preview.test.ts new file mode 100644 index 00000000..685be5d3 --- /dev/null +++ b/apps/web/src/features/trade/lib/fee-preview.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { calculateDepthImpact, getProtectionPrice, type VerifiedDepth } from "./fee-preview" + +const depth: VerifiedDepth = { + source: "verified", + updatedAt: 1_000, + asks: [{ price: 101, size: 5 }, { price: 102, size: 10 }], + bids: [{ price: 99, size: 5 }, { price: 98, size: 10 }], +} + +describe("fee and execution preview", () => { + it("calculates adverse impact on both sides from verified depth", () => { + const long = calculateDepthImpact({ side: "long", orderValueUsd: 505, referencePrice: 100, depth, now: 1_000 }) + const short = calculateDepthImpact({ side: "short", orderValueUsd: 495, referencePrice: 100, depth, now: 1_000 }) + + expect(long.status).toBe("estimated") + expect(long.impactPct).toBeGreaterThan(0) + expect(short.status).toBe("estimated") + expect(short.impactPct).toBeGreaterThan(0) + }) + + it("reports insufficient depth instead of returning zero impact", () => { + const result = calculateDepthImpact({ side: "long", orderValueUsd: 10_000, referencePrice: 100, depth, now: 1_000 }) + expect(result.status).toBe("insufficient") + expect(result.impactPct).toBeNull() + }) + + it("reports stale and reference depth explicitly", () => { + expect(calculateDepthImpact({ side: "long", orderValueUsd: 100, referencePrice: 100, depth, now: 7_000 }).status).toBe("stale") + expect(calculateDepthImpact({ + side: "long", + orderValueUsd: 100, + referencePrice: 100, + depth: { ...depth, source: "reference" }, + now: 1_000, + }).status).toBe("unknown") + }) + + it("uses the same protection bounds for preview and submission", () => { + expect(getProtectionPrice({ referencePrice: 100, isLong: true, slippagePct: 0.5 })).toBeCloseTo(100.5, 8) + expect(getProtectionPrice({ referencePrice: 100, isLong: false, slippagePct: 0.5 })).toBeCloseTo(99.5, 8) + }) +}) diff --git a/apps/web/src/features/trade/lib/fee-preview.ts b/apps/web/src/features/trade/lib/fee-preview.ts new file mode 100644 index 00000000..3ee3aead --- /dev/null +++ b/apps/web/src/features/trade/lib/fee-preview.ts @@ -0,0 +1,89 @@ +export type PreviewStatus = "estimated" | "unknown" | "stale" | "insufficient" + +export type DepthLevel = { + price: number + size: number +} + +export type VerifiedDepth = { + source: "verified" + updatedAt: number + bids: Array + asks: Array +} + +export type DepthSnapshot = VerifiedDepth | { + source: "reference" + updatedAt: number + bids: Array + asks: Array +} + +export type DepthImpact = { + status: PreviewStatus + impactPct: number | null + averagePrice: number | null + filledNotionalUsd: number +} + +export const DEPTH_MAX_AGE_MS = 5_000 + +/** + * Calculate adverse price impact from executable venue depth only. + * Reference books are deliberately not accepted as fillable liquidity. + */ +export function calculateDepthImpact(params: { + side: "long" | "short" + orderValueUsd: number + referencePrice: number + depth: DepthSnapshot | null | undefined + now?: number + maxAgeMs?: number +}): DepthImpact { + const { side, orderValueUsd, referencePrice, depth } = params + if (!depth || depth.source !== "verified") { + return { status: "unknown", impactPct: null, averagePrice: null, filledNotionalUsd: 0 } + } + if (!Number.isFinite(depth.updatedAt) || (params.now ?? Date.now()) - depth.updatedAt > (params.maxAgeMs ?? DEPTH_MAX_AGE_MS)) { + return { status: "stale", impactPct: null, averagePrice: null, filledNotionalUsd: 0 } + } + if (!Number.isFinite(orderValueUsd) || orderValueUsd <= 0 || !Number.isFinite(referencePrice) || referencePrice <= 0) { + return { status: "unknown", impactPct: null, averagePrice: null, filledNotionalUsd: 0 } + } + + const levels = side === "long" ? depth.asks : depth.bids + let remaining = orderValueUsd + let filledNotionalUsd = 0 + let filledTokens = 0 + + for (const level of levels) { + if (!Number.isFinite(level.price) || !Number.isFinite(level.size) || level.price <= 0 || level.size <= 0) continue + const availableNotionalUsd = level.price * level.size + const fillNotionalUsd = Math.min(remaining, availableNotionalUsd) + filledNotionalUsd += fillNotionalUsd + filledTokens += fillNotionalUsd / level.price + remaining -= fillNotionalUsd + if (remaining <= 0) break + } + + if (remaining > 0 || filledTokens <= 0) { + return { status: "insufficient", impactPct: null, averagePrice: null, filledNotionalUsd } + } + + const averagePrice = filledNotionalUsd / filledTokens + const impactPct = side === "long" + ? ((averagePrice - referencePrice) / referencePrice) * 100 + : ((referencePrice - averagePrice) / referencePrice) * 100 + return { status: "estimated", impactPct: Math.max(0, impactPct), averagePrice, filledNotionalUsd } +} + +export function getProtectionPrice(params: { + referencePrice: number + isLong: boolean + slippagePct: number +}): number | null { + const { referencePrice, isLong, slippagePct } = params + if (!Number.isFinite(referencePrice) || referencePrice <= 0 || !Number.isFinite(slippagePct) || slippagePct < 0) return null + const factor = slippagePct / 100 + return isLong ? referencePrice * (1 + factor) : referencePrice * (1 - factor) +} diff --git a/apps/web/src/features/trade/lib/oracle.ts b/apps/web/src/features/trade/lib/oracle.ts index d04bc94e..5a1985a8 100644 --- a/apps/web/src/features/trade/lib/oracle.ts +++ b/apps/web/src/features/trade/lib/oracle.ts @@ -28,6 +28,7 @@ export type OhlcBar = { high: number low: number close: number + volume?: number } export type PriceDelta24h = { @@ -349,6 +350,7 @@ export async function fetchOracleCandles( high: parseFloat(c[2] as string), low: parseFloat(c[3] as string), close: parseFloat(c[4] as string), + volume: Number(c[5]), })) return { diff --git a/apps/web/src/features/trade/lib/pricing.ts b/apps/web/src/features/trade/lib/pricing.ts index 3e66fec4..ead49d58 100644 --- a/apps/web/src/features/trade/lib/pricing.ts +++ b/apps/web/src/features/trade/lib/pricing.ts @@ -1,14 +1,14 @@ -export function getPriceImpactPct(sizeUsd: number, priceImpactUsd: number): number { - if (sizeUsd <= 0) return 0 +export function getPriceImpactPct(sizeUsd: number, priceImpactUsd: number | null): number | null { + if (sizeUsd <= 0 || priceImpactUsd === null) return null return (priceImpactUsd / sizeUsd) * 100 } export function getEstimatedEntryPrice( entryPrice: number, - priceImpactPct: number, + priceImpactPct: number | null, isLong: boolean, -): number { - if (entryPrice <= 0) return 0 +): number | null { + if (entryPrice <= 0 || priceImpactPct === null) return null const magnitude = Math.abs(priceImpactPct) / 100 return isLong ? entryPrice * (1 + magnitude) : entryPrice * (1 - magnitude) } diff --git a/apps/web/src/features/trade/lib/risk.test.ts b/apps/web/src/features/trade/lib/risk.test.ts new file mode 100644 index 00000000..b10c8f15 --- /dev/null +++ b/apps/web/src/features/trade/lib/risk.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest" +import { clampLeverage, estimatePositionRisk, getRiskState } from "./risk" + +const risk = (overrides: Partial[0]["risk"]> = {}) => ({ + maxLeverage: 20, + maintenanceMarginRateBps: 50, + updatedAt: Date.now(), + ...overrides, +}) + +describe("source-driven trade risk", () => { + it("clamps leverage at the source maximum", () => { + expect(clampLeverage(25, 20)).toBe(20) + expect(clampLeverage(0.5, 20)).toBe(1) + }) + + it("recomputes notional and margin when collateral changes", () => { + const first = estimatePositionRisk({ + collateralUsd: 100, + leverage: 10, + entryPrice: 1_000, + isLong: true, + risk: risk(), + }) + const second = estimatePositionRisk({ + collateralUsd: 250, + leverage: 10, + entryPrice: 1_000, + isLong: true, + risk: risk(), + }) + expect(first).toMatchObject({ collateralUsd: 100, notionalUsd: 1_000 }) + expect(second).toMatchObject({ collateralUsd: 250, notionalUsd: 2_500 }) + }) + + it("includes existing exposure in the estimate", () => { + const estimate = estimatePositionRisk({ + collateralUsd: 100, + leverage: 10, + entryPrice: 1_000, + isLong: true, + risk: risk(), + existing: { sizeUsd: 500, collateralUsd: 75, entryPrice: 900 }, + }) + expect(estimate).toMatchObject({ collateralUsd: 100, notionalUsd: 1_500 }) + expect(estimate?.liquidationPrice).toBeLessThan(1_000) + }) + + it("does not estimate when risk is unavailable or stale", () => { + expect(getRiskState(null)).toBe("unavailable") + expect(getRiskState(risk({ updatedAt: Date.now() - 6 * 60 * 1000 }))).toBe("stale") + expect(estimatePositionRisk({ + collateralUsd: 100, + leverage: 10, + entryPrice: 1_000, + isLong: true, + risk: risk({ updatedAt: Date.now() - 6 * 60 * 1000 }), + })).toBeNull() + }) + + it("does not show a precise liquidation value without maintenance margin", () => { + const estimate = estimatePositionRisk({ + collateralUsd: 100, + leverage: 10, + entryPrice: 1_000, + isLong: true, + risk: risk({ maintenanceMarginRateBps: null }), + }) + expect(estimate).toMatchObject({ notionalUsd: 1_000, liquidationPrice: null }) + }) +}) diff --git a/apps/web/src/features/trade/lib/risk.ts b/apps/web/src/features/trade/lib/risk.ts new file mode 100644 index 00000000..49961082 --- /dev/null +++ b/apps/web/src/features/trade/lib/risk.ts @@ -0,0 +1,87 @@ +import { estimateLiquidationPrice } from "./liquidation" + +export type MarketRiskParams = { + maxLeverage: number + maintenanceMarginRateBps: number | null + updatedAt: number +} + +export type ExistingExposure = { + sizeUsd: number + collateralUsd: number + entryPrice: number +} + +export type RiskState = "available" | "stale" | "unavailable" + +export type PositionEstimate = { + collateralUsd: number + notionalUsd: number + liquidationPrice: number | null +} + +export const RISK_MAX_AGE_MS = 5 * 60 * 1000 + +export function getRiskState( + params: MarketRiskParams | null | undefined, + now = Date.now(), + maxAgeMs = RISK_MAX_AGE_MS, +): RiskState { + if ( + !params || + !Number.isFinite(params.maxLeverage) || + params.maxLeverage < 1 || + !Number.isFinite(params.updatedAt) + ) return "unavailable" + return now - params.updatedAt > maxAgeMs ? "stale" : "available" +} + +export function clampLeverage(value: number, maxLeverage: number): number { + if (!Number.isFinite(maxLeverage) || maxLeverage < 1) return 0 + if (!Number.isFinite(value)) return 1 + return Math.min(Math.max(value, 1), maxLeverage) +} + +export function estimatePositionRisk(params: { + collateralUsd: number + leverage: number + entryPrice: number + isLong: boolean + risk: MarketRiskParams + existing?: ExistingExposure | null +}): PositionEstimate | null { + const { collateralUsd, entryPrice, isLong, risk, existing } = params + const leverage = clampLeverage(params.leverage, risk.maxLeverage) + if ( + getRiskState(risk) !== "available" || + leverage <= 0 || + collateralUsd <= 0 || + entryPrice <= 0 + ) return null + + const newNotionalUsd = collateralUsd * leverage + const existingSize = existing && existing.sizeUsd > 0 ? existing.sizeUsd : 0 + const existingCollateral = existing && existing.collateralUsd > 0 ? existing.collateralUsd : 0 + const notionalUsd = existingSize + newNotionalUsd + const totalCollateralUsd = existingCollateral + collateralUsd + + let liquidationPrice: number | null = null + if (risk.maintenanceMarginRateBps !== null && Number.isFinite(risk.maintenanceMarginRateBps)) { + const existingTokens = existing && existing.entryPrice > 0 + ? existingSize / existing.entryPrice + : 0 + const newTokens = newNotionalUsd / entryPrice + const averageEntryPrice = existingTokens + newTokens > 0 + ? notionalUsd / (existingTokens + newTokens) + : entryPrice + liquidationPrice = estimateLiquidationPrice({ + entryPrice: averageEntryPrice, + collateralUsd: totalCollateralUsd, + sizeUsd: notionalUsd, + isLong, + maintenanceMarginRateBps: risk.maintenanceMarginRateBps, + }) + } + + return { collateralUsd, notionalUsd, liquidationPrice } +} diff --git a/apps/web/src/features/trade/lib/stellar.ts b/apps/web/src/features/trade/lib/stellar.ts index 47e26441..20676f5e 100644 --- a/apps/web/src/features/trade/lib/stellar.ts +++ b/apps/web/src/features/trade/lib/stellar.ts @@ -32,6 +32,7 @@ import { prepareAndSign } from "@/lib/soroban/tx-builder" import { formatUsd } from "@/shared/lib/format" import { submitTx } from "@/shared/hooks/useTxSubmit" import { invalidateMutationOutcome } from "@/shared/lib/mutation-invalidation" +import { validateExecutionRequest } from "./execution-support" const CHAIN_ID = activeQueryNetwork() @@ -450,18 +451,23 @@ export async function sendBatchOrderTxn( } /** - * Create a TP/SL sidecar order as a real decrease order with a trigger price. - * - * takeProfit → LimitDecrease (executes when price moves favourably) - * stopLoss → StopLossDecrease (executes to cap downside) - * - * sizePct% of parentSizeUsd is closed. For full close pass sizePct=100. + * Kept as a defensive boundary for callers that still hold an old sidecar + * draft. The current gateway does not verify attached TP/SL semantics. */ export async function createSidecarOrder(params: SidecarOrderParams): Promise { if (!isValidAccount(params.account)) { throw new Error("Connect your wallet before placing a TP/SL order.") } + const support = validateExecutionRequest({ + attachedOrders: [{ + type: params.type, + triggerPrice: String(params.triggerPrice), + sizePct: params.sizePct, + }], + }) + if (!support.valid) throw new Error(support.reason) + const sizeDeltaUsd = params.parentSizeUsd * (params.sizePct / 100) const orderType: CreateOrderParams["orderType"] = params.type === "takeProfit" ? "LimitDecrease" : "StopLossDecrease" diff --git a/apps/web/src/lib/graphql/queries.ts b/apps/web/src/lib/graphql/queries.ts index 507dbecf..17f0f559 100644 --- a/apps/web/src/lib/graphql/queries.ts +++ b/apps/web/src/lib/graphql/queries.ts @@ -9,6 +9,7 @@ import type { Deposit, FeeClaim, Market, + MarketConfigSnapshot, Order, PoolBalanceSnapshot, Position, @@ -138,6 +139,26 @@ export const GET_MARKETS = gql<{ markets: { nodes: Array } }, Record> } }, + { marketKey: string } +>(` + query GetMarketRiskParams($marketKey: String!) { + markets(filter: { key: { equalTo: $marketKey } }, first: 1) { + nodes { + key + status + latestConfigSnapshot { + maxLeverage + timestamp + ledger + } } } } diff --git a/apps/web/src/lib/graphql/query-keys.ts b/apps/web/src/lib/graphql/query-keys.ts index 46890c91..fa49676e 100644 --- a/apps/web/src/lib/graphql/query-keys.ts +++ b/apps/web/src/lib/graphql/query-keys.ts @@ -5,4 +5,71 @@ * re-export avoids a risky import-path migration while eliminating the * previously independent indexer registry. */ + +import { INDEXER_CONFIG } from "@/app/config/indexer" + +// ───────────────────────────────────────────────────────────────────────────── +// Query Key Factory +// ───────────────────────────────────────────────────────────────────────────── + +export const indexerQueryKeys = { + /** Base key for all indexer queries (includes network) */ + all: () => ["indexer", INDEXER_CONFIG.network] as const, + + /** Position queries */ + positions: { + all: () => [...indexerQueryKeys.all(), "positions"] as const, + byAccount: (account: string) => [...indexerQueryKeys.positions.all(), account] as const, + }, + + /** Order queries */ + orders: { + all: () => [...indexerQueryKeys.all(), "orders"] as const, + byAccount: (account: string) => [...indexerQueryKeys.orders.all(), account] as const, + }, + + /** Market queries */ + markets: { + all: () => [...indexerQueryKeys.all(), "markets"] as const, + byKey: (key: string) => [...indexerQueryKeys.markets.all(), key] as const, + risk: (key: string) => [...indexerQueryKeys.markets.byKey(key), "risk"] as const, + }, + + /** Pool queries */ + pools: { + all: () => [...indexerQueryKeys.all(), "pools"] as const, + snapshots: (marketKey: string) => [...indexerQueryKeys.pools.all(), "snapshots", marketKey] as const, + }, + + /** Deposit queries */ + deposits: { + all: () => [...indexerQueryKeys.all(), "deposits"] as const, + byAccount: (account: string) => [...indexerQueryKeys.deposits.all(), account] as const, + }, + + /** Withdrawal queries */ + withdrawals: { + all: () => [...indexerQueryKeys.all(), "withdrawals"] as const, + byAccount: (account: string) => [...indexerQueryKeys.withdrawals.all(), account] as const, + }, + + /** Trade history queries */ + tradeHistory: { + all: () => [...indexerQueryKeys.all(), "tradeHistory"] as const, + byAccount: (account: string) => [...indexerQueryKeys.tradeHistory.all(), account] as const, + }, + + /** Referral queries */ + referrals: { + all: () => [...indexerQueryKeys.all(), "referrals"] as const, + traderReferral: (trader: string) => [...indexerQueryKeys.referrals.all(), "trader", trader] as const, + affiliateTraders: (owner: string) => [...indexerQueryKeys.referrals.all(), "affiliate", owner] as const, + }, + + /** Fee queries */ + fees: { + all: () => [...indexerQueryKeys.all(), "fees"] as const, + byAccount: (account: string) => [...indexerQueryKeys.fees.all(), account] as const, + }, +} export { indexerQueryKeys } from "@/shared/lib/query-keys" diff --git a/apps/web/src/lib/graphql/types.ts b/apps/web/src/lib/graphql/types.ts index 594ce86f..2e78502e 100644 --- a/apps/web/src/lib/graphql/types.ts +++ b/apps/web/src/lib/graphql/types.ts @@ -48,6 +48,7 @@ export type Market = { createdLedger: number createdTimestamp: Date createdTransactionHash: string + latestConfigSnapshot?: MarketConfigSnapshot | null } export type MarketConfigSnapshot = {