= {}): LpPosition {
poolAddress: "Pool123456",
provider: "meteora",
pair: "SOL-USDC",
+ quote: "USDC",
state: "IN_RANGE",
lowerPrice: 132.1,
upperPrice: 147.9,
@@ -132,6 +135,9 @@ interface RenderOpts {
holds?: ConsolidatedPosition[];
lpPositions?: LpPosition[];
isLoading?: boolean;
+ /** Override the euro stand-in to exercise the real rate seam's fallback. */
+ formatValue?: (val: number, quote?: string) => string;
+ formatPnlValue?: (val: number, quote?: string) => string;
}
async function render(opts: RenderOpts = {}) {
@@ -150,8 +156,8 @@ async function render(opts: RenderOpts = {}) {
isLoading={opts.isLoading ?? false}
// Halve the value, so a row that skipped conversion is visible.
convert={(value) => ({ value: value / 2, converted: true })}
- formatValue={(val) => fmtValue(val / 2)}
- formatPnlValue={(val) => fmtPnl(val / 2)}
+ formatValue={opts.formatValue ?? ((val) => fmtValue(val / 2))}
+ formatPnlValue={opts.formatPnlValue ?? ((val) => fmtPnl(val / 2))}
/>
,
@@ -268,6 +274,31 @@ describe("liquidity", () => {
await render({ lpPositions: [lpPosition({ state: "OUT_OF_RANGE" })] });
expect(document.querySelector("[data-lp-row]")!.textContent).toContain("Out of range");
});
+
+ it("renders the range's money in the display currency, not a bare dollar", async () => {
+ // valueQuote/feesQuote/pnl are quote-denominated (SOL); the row must run
+ // through the page's rate seam rather than stamp them with a `$`.
+ await render({ lpPositions: [lpPosition()] });
+
+ const row = document.querySelector("[data-lp-row]")!.textContent!;
+ expect(row).toContain("€");
+ expect(row).not.toContain("$");
+ });
+
+ it("labels an unconvertible SOL range with SOL, not a dollar", async () => {
+ // P1: with no rate path for SOL the seam must not stamp the quote-
+ // denominated figure `$` — it keeps the quote's own ticker.
+ const rates = {}; // no path for SOL → the fallback branch
+ await render({
+ lpPositions: [lpPosition({ pair: "TOKEN-SOL", quote: "SOL" })],
+ formatValue: (val, quote) => formatWithRate(formatCurrencyVolume, rates, "EUR")(val, quote),
+ formatPnlValue: (val, quote) => formatWithRate(formatCurrencyPnl, rates, "EUR")(val, quote),
+ });
+
+ const row = document.querySelector("[data-lp-row]")!.textContent!;
+ expect(row).toContain("SOL ⚠");
+ expect(row).not.toContain("$");
+ });
});
describe("the tab never mutates anything", () => {
diff --git a/frontend/src/components/portfolio/PositionsTab.tsx b/frontend/src/components/portfolio/PositionsTab.tsx
index f71c29be7..6fc1fa658 100644
--- a/frontend/src/components/portfolio/PositionsTab.tsx
+++ b/frontend/src/components/portfolio/PositionsTab.tsx
@@ -2,9 +2,9 @@ import { ArrowRight, Layers } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
-import { feeAmount, lpStateStyle, rangeFraction, type LpPosition } from "@/components/dex/lp-position";
+import { lpStateStyle, rangeFraction, type LpPosition } from "@/components/dex/lp-position";
import { type ConsolidatedPosition } from "@/lib/api";
-import { formatPnl, formatPriceSig, formatUsd, pnlColor } from "@/lib/formatters";
+import { formatPriceSig, pnlColor } from "@/lib/formatters";
/** The quote a hold's numbers are denominated in — its pair's, as elsewhere. */
function quoteOf(pair: string): string {
@@ -294,13 +294,17 @@ export function PositionsTab({
|
- {pos.valueQuote === null ? "—" : formatUsd(pos.valueQuote)}
+ {pos.valueQuote === null
+ ? "—"
+ : formatValue(pos.valueQuote, pos.quote)}
|
- {pos.feesQuote === null ? "—" : feeAmount(pos.feesQuote)}
+ {pos.feesQuote === null
+ ? "—"
+ : formatValue(pos.feesQuote, pos.quote)}
|
- {formatPnl(pos.pnl)}
+ {formatPnlValue(pos.pnl, pos.quote)}
|
diff --git a/frontend/src/hooks/useLpPositions.test.tsx b/frontend/src/hooks/useLpPositions.test.tsx
index fa504ee64..5022cca2d 100644
--- a/frontend/src/hooks/useLpPositions.test.tsx
+++ b/frontend/src/hooks/useLpPositions.test.tsx
@@ -70,6 +70,20 @@ function Harness() {
return null;
}
+/** Renders the USD price the hook resolved for each range, in its own order. */
+function PriceProbe() {
+ const { positions, quoteUsd } = useLpPositions("srv");
+ return (
+
+ {positions.map((p) => (
+
+ {quoteUsd(p) ?? "none"}
+
+ ))}
+
+ );
+}
+
let container: HTMLDivElement;
let root: Root;
let client: QueryClient;
@@ -190,4 +204,38 @@ describe("useLpPositions", () => {
await poll([executor("1", "base", "pool-a", 900)]);
expect(getDexPoolsByAddress).toHaveBeenCalledTimes(2);
});
+
+ it("reads a portfolio past 30 ranges in batches, pricing the ones a single request would drop", async () => {
+ // GeckoTerminal takes 30 addresses per request, so 31 ranges must become two
+ // requests — the old code sliced to the first 30 and left the rest unpriced.
+ const many = Array.from({ length: 31 }, (_, i) =>
+ executor(`e${i}`, "solana", `pool-${i}`, 1000 - i),
+ );
+ getExecutors.mockResolvedValue(many);
+ getDexPoolsByAddress.mockImplementation(
+ async (_server: string, _network: string, addresses: string[]) => ({
+ pools: addresses.map((address) => ({ address, quote_token_price_usd: 2.5 })),
+ }),
+ );
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await settle();
+ await settle();
+
+ // No request carries more than 30 addresses, and every range is asked for.
+ const batches = getDexPoolsByAddress.mock.calls.map((c) => (c[2] as string[]).length);
+ expect([...batches].sort((a, b) => a - b)).toEqual([1, 30]);
+ const asked = getDexPoolsByAddress.mock.calls.flatMap((c) => c[2] as string[]);
+ expect(new Set(asked).size).toBe(31);
+
+ // The 31st range — the one the slice dropped — shows its USD price rather
+ // than falling back to quote units.
+ expect(document.querySelector('[data-pool="pool-30"]')?.textContent).toBe("2.5");
+ });
});
diff --git a/frontend/src/hooks/useLpPositions.ts b/frontend/src/hooks/useLpPositions.ts
index 517fcbf6c..c5d15ab91 100644
--- a/frontend/src/hooks/useLpPositions.ts
+++ b/frontend/src/hooks/useLpPositions.ts
@@ -12,8 +12,20 @@ import {
import { api, type PoolSummary } from "@/lib/api";
import { isExecutorActive } from "@/lib/formatters";
-/** GeckoTerminal's multi-pool endpoint, which the labels come from, caps here. */
-const MAX_LABELLED_POOLS = 30;
+/**
+ * GeckoTerminal takes at most 30 addresses per request, so a larger portfolio is
+ * read as several requests rather than truncated. The backend splits on the same
+ * bound (`fetch_pools_by_addresses`); chunking here too, instead of sending every
+ * address in one `addresses=` list, is what keeps the request URL short.
+ */
+const POOLS_PER_REQUEST = 30;
+
+/** `items` split into consecutive runs of at most `size`. */
+function batches(items: T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
+ return out;
+}
/**
* The pool-label query key as a *set*, not as a ranking.
@@ -68,6 +80,12 @@ export function useLpPositions(server: string | null): {
label: (pos: LpPosition) => string;
/** The venue as the pool row names it — a fallback for a missing `provider`. */
dexId: (pos: LpPosition) => string;
+ /**
+ * The USD price of the position's quote token, or `null` when the pool row
+ * did not carry one — the caller then leaves the figure in quote units rather
+ * than stamping it with a dollar it may not be.
+ */
+ quoteUsd: (pos: LpPosition) => number | null;
isLoading: boolean;
} {
const { data: executors = [], isLoading } = useQuery({
@@ -100,7 +118,7 @@ export function useLpPositions(server: string | null): {
// not silently fall back to mint addresses.
const byNetwork = useMemo(() => {
const groups: Record = {};
- for (const pos of positions.slice(0, MAX_LABELLED_POOLS)) {
+ for (const pos of positions) {
const list = (groups[pos.network] ??= []);
if (!list.includes(pos.poolAddress)) list.push(pos.poolAddress);
}
@@ -111,11 +129,13 @@ export function useLpPositions(server: string | null): {
queryKey: ["dex-lp-pools", server, lpPoolsKey(byNetwork)],
queryFn: async () => {
const entries = await Promise.all(
- Object.entries(byNetwork).map(([network, addresses]) =>
- api
- .getDexPoolsByAddress(server!, network, addresses)
- .then((r) => r.pools)
- .catch(() => [] as PoolSummary[]),
+ Object.entries(byNetwork).flatMap(([network, addresses]) =>
+ batches(addresses, POOLS_PER_REQUEST).map((batch) =>
+ api
+ .getDexPoolsByAddress(server!, network, batch)
+ .then((r) => r.pools)
+ .catch(() => [] as PoolSummary[]),
+ ),
),
);
const map: Record = {};
@@ -137,5 +157,13 @@ export function useLpPositions(server: string | null): {
[pools],
);
- return { positions, label, dexId, isLoading };
+ // The pool's own `quote_token_price_usd` prices the executor's quote-
+ // denominated figures (SOL on an X-SOL range) in dollars, so the cards need
+ // no rates query of their own. Absent for a pool that did not resolve.
+ const quoteUsd = useMemo(
+ () => (pos: LpPosition) => pools[pos.poolAddress]?.quote_token_price_usd ?? null,
+ [pools],
+ );
+
+ return { positions, label, dexId, quoteUsd, isLoading };
}
diff --git a/frontend/src/lib/rates.test.ts b/frontend/src/lib/rates.test.ts
index f0d527229..cf12305f4 100644
--- a/frontend/src/lib/rates.test.ts
+++ b/frontend/src/lib/rates.test.ts
@@ -77,9 +77,19 @@ describe("formatWithRate", () => {
expect(out).not.toContain("€");
});
- it("falls back to $ for a quote it has no symbol for", () => {
+ it("keeps a stablecoin's $ even before a rate lands", () => {
+ // USDC has no display symbol of its own, but it *is* a dollar: suffixing
+ // the common X-USDC row "USDC" would only mislabel it the other way.
const fmt = formatWithRate(formatCurrency, {}, "EUR");
- expect(fmt(500, "PLN")).toBe(`${formatCurrency(500, "$")} ⚠`);
+ expect(fmt(500, "USDC")).toBe(`${formatCurrency(500, "$")} ⚠`);
+ });
+
+ it("labels a quote it has no symbol for with the quote's own ticker", () => {
+ // 0.163 SOL read `$0.16 ⚠` — a dollar it is not. The ticker goes *after*
+ // the number, where the /dex surfaces already put it.
+ const fmt = formatWithRate(formatCurrency, {}, "EUR");
+ expect(fmt(500, "PLN")).toBe("500.00 PLN ⚠");
+ expect(fmt(0.163, "SOL")).toBe("0.16 SOL ⚠");
});
it("leaves the unconverted value untouched — only its label changes", () => {
diff --git a/frontend/src/lib/rates.ts b/frontend/src/lib/rates.ts
index 468b5a1f7..24e06546d 100644
--- a/frontend/src/lib/rates.ts
+++ b/frontend/src/lib/rates.ts
@@ -71,19 +71,26 @@ export function resolveSymbol(rates: RateTable, currency: DisplayCurrency): stri
}
/**
- * The symbol of a value that could *not* be converted. It is still in quote
- * units, so it keeps the quote's own symbol — `$` for a quote the dashboard
- * has no symbol for. Relabelling it with the display currency would not be a
- * formatting detail; it would be a wrong number on screen.
+ * How a value that could *not* be converted must be labelled. It is still in
+ * quote units, so it carries the *quote's* own label and never the display
+ * currency's. A quote the dashboard has a symbol for (USDT, BTC, BRL, EUR) —
+ * or a USD-pegged stablecoin, which *is* a dollar — is prefixed with that
+ * symbol. Any other quote (SOL, ETH, …) has no symbol, so it keeps its own
+ * ticker, *suffixed*: a 0.163 SOL range stamped `$0.16` is the mislabelling
+ * this rule exists to prevent, not a rounding detail.
*/
-function quoteSymbol(quote?: string): string {
- return CURRENCY_SYMBOLS[normalizeQuote(quote) as DisplayCurrency] || "$";
+function quoteLabel(quote?: string): { label: string; prefix: boolean } {
+ const q = normalizeQuote(quote);
+ const symbol = CURRENCY_SYMBOLS[q as DisplayCurrency];
+ if (symbol) return { label: symbol, prefix: true };
+ if (STABLECOINS.has(q)) return { label: "$", prefix: true };
+ return { label: q, prefix: false };
}
/**
* `fmt` bound to a rate table and a display currency: converts and labels with
* the display currency, or leaves the value in quote units under the quote's
- * symbol with the `⚠` marker.
+ * own label (see `quoteLabel`) with the `⚠` marker.
*/
export function formatWithRate(
fmt: (val: number, symbol?: string) => string,
@@ -92,8 +99,8 @@ export function formatWithRate(
): (val: number, quote?: string) => string {
return (val: number, quote?: string): string => {
const rate = rateFor(rates, currency, quote);
- return rate != null
- ? fmt(val / rate, CURRENCY_SYMBOLS[currency])
- : `${fmt(val, quoteSymbol(quote))} ⚠`;
+ if (rate != null) return fmt(val / rate, CURRENCY_SYMBOLS[currency]);
+ const { label, prefix } = quoteLabel(quote);
+ return prefix ? `${fmt(val, label)} ⚠` : `${fmt(val, "")} ${label} ⚠`;
};
}
diff --git a/frontend/src/pages/DexPool.tsx b/frontend/src/pages/DexPool.tsx
index 2d9f057fe..09654fc35 100644
--- a/frontend/src/pages/DexPool.tsx
+++ b/frontend/src/pages/DexPool.tsx
@@ -534,6 +534,7 @@ export function DexPool() {
setSelectedExecutorId((prev) => (prev === id ? null : id))}
/>
diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx
index 858001eaf..ecc4d0835 100644
--- a/frontend/src/pages/Portfolio.tsx
+++ b/frontend/src/pages/Portfolio.tsx
@@ -875,8 +875,13 @@ export function Portfolio() {
for (const pos of holds) {
quotes.add(pos.trading_pair?.split("-")[1] || "USDT");
}
+ // The liquidity table's money is quote-denominated too (SOL on an X-SOL
+ // range); without its quote here the row falls back to the quote's symbol.
+ for (const pos of lpPositions) {
+ quotes.add(pos.quote);
+ }
return Array.from(quotes);
- }, [controllers, executorsList, holds]);
+ }, [controllers, executorsList, holds, lpPositions]);
const {
convert,
formatValueDetailed,
|