= {}): LpPosition {
poolAddress: "Pool123456",
provider: "meteora",
pair: "SOL-USDC",
+ quote: "USDC",
state: "IN_RANGE",
lowerPrice: 132.1,
upperPrice: 147.9,
@@ -132,6 +139,10 @@ 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;
+ formatFeeValue?: (val: number, quote?: string) => string;
}
async function render(opts: RenderOpts = {}) {
@@ -150,8 +161,9 @@ 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))}
+ formatFeeValue={opts.formatFeeValue ?? ((val) => fmtValue(val / 2))}
/>
,
@@ -268,6 +280,44 @@ 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("$");
+ });
+
+ it("keeps a sub-cent fee instead of rounding it to zero", async () => {
+ // P2: the fee column went through `formatValue`, whose `$` branch has two
+ // decimals — 0.00136 USDC read `$0.00` while the /dex cards kept the digits.
+ await render({
+ lpPositions: [lpPosition({ feesQuote: 0.00136 })],
+ formatFeeValue: (val, quote) =>
+ formatWithRate(formatCurrencyPrecise, {}, "USDT")(val, quote),
+ });
+
+ const row = document.querySelector("[data-lp-row]")!.textContent!;
+ expect(row).toContain("$0.0014");
+ });
});
describe("the tab never mutates anything", () => {
@@ -317,6 +367,7 @@ describe("an LP range older than the newest page of executors", () => {
convert={(value) => ({ value, converted: true })}
formatValue={fmtValue}
formatPnlValue={fmtPnl}
+ formatFeeValue={fmtValue}
/>
);
}
diff --git a/frontend/src/components/portfolio/PositionsTab.tsx b/frontend/src/components/portfolio/PositionsTab.tsx
index f71c29be7..ec3dc36dc 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 {
@@ -64,6 +64,8 @@ interface Props {
convert: (value: number, quote: string) => { value: number; converted: boolean };
formatValue: (val: number, quote: string) => string;
formatPnlValue: (val: number, quote: string) => string;
+ /** Same conversion as `formatValue`, but a sub-cent fee keeps its digits. */
+ formatFeeValue: (val: number, quote: string) => string;
}
/**
@@ -86,6 +88,7 @@ export function PositionsTab({
convert,
formatValue,
formatPnlValue,
+ formatFeeValue,
}: Props) {
const navigate = useNavigate();
@@ -294,13 +297,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
+ ? "—"
+ : formatFeeValue(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..380bc1c6b 100644
--- a/frontend/src/hooks/useLpPositions.test.tsx
+++ b/frontend/src/hooks/useLpPositions.test.tsx
@@ -23,6 +23,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { LP_REFRESH_MS } from "@/components/dex/lp-position";
import type { ExecutorInfo } from "@/lib/api";
const getExecutors = vi.fn();
@@ -70,6 +71,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 +205,69 @@ 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");
+ });
+
+ it("reprices on the positions' own beat, so a page left open is not frozen", async () => {
+ // The pool row carries the quote's USD price the cards multiply by; without
+ // a timer that price stays at whatever it was when the page opened.
+ vi.useFakeTimers();
+ try {
+ getExecutors.mockResolvedValue([executor("1", "solana", "pool-a", 900)]);
+ getDexPoolsByAddress.mockResolvedValue({
+ pools: [{ address: "pool-a", quote_token_price_usd: 2.5 }],
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10);
+ });
+ expect(getDexPoolsByAddress).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(LP_REFRESH_MS + 1_000);
+ });
+ expect(getDexPoolsByAddress).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/frontend/src/hooks/useLpPositions.ts b/frontend/src/hooks/useLpPositions.ts
index 517fcbf6c..35ebcef10 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 = {};
@@ -123,8 +143,11 @@ export function useLpPositions(server: string | null): {
return map;
},
enabled: !!server && Object.keys(byNetwork).length > 0,
- // A pair label does not change; only the TVL beside it does.
+ // A pair label does not change, but the quote's USD price beside it does —
+ // the cards multiply by it, so a page left open must not freeze at the
+ // price it was opened on. Same beat as the positions it prices.
staleTime: 5 * 60_000,
+ refetchInterval: LP_REFRESH_MS,
});
const label = useMemo(
@@ -137,5 +160,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/hooks/useRates.ts b/frontend/src/hooks/useRates.ts
index 327a61717..77364c7b0 100644
--- a/frontend/src/hooks/useRates.ts
+++ b/frontend/src/hooks/useRates.ts
@@ -2,7 +2,12 @@ import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { api } from "@/lib/api";
-import { formatCurrency, formatCurrencyPnl, formatCurrencyVolume } from "@/lib/formatters";
+import {
+ formatCurrency,
+ formatCurrencyPrecise,
+ formatCurrencyPnl,
+ formatCurrencyVolume,
+} from "@/lib/formatters";
import { formatWithRate, rateFor, resolveSymbol, STABLECOINS, type ConvertFn } from "@/lib/rates";
import { useDisplayCurrency } from "./useDisplayCurrency";
import { useServer } from "./useServer";
@@ -102,12 +107,19 @@ export function useRates(quoteCurrencies: string[]) {
[rates, currency],
);
+ // Fees keep sub-cent precision: a 0.00136 USDC fee must not read `$0.00`.
+ const formatFeeValue = useMemo(
+ () => formatWithRate(formatCurrencyPrecise, rates, currency),
+ [rates, currency],
+ );
+
return {
rates: rates ?? {},
convert,
formatValue,
formatPnlValue,
formatValueDetailed,
+ formatFeeValue,
isLoading,
currency,
currencySymbol,
diff --git a/frontend/src/lib/formatters.test.ts b/frontend/src/lib/formatters.test.ts
index c9149fee5..fae129096 100644
--- a/frontend/src/lib/formatters.test.ts
+++ b/frontend/src/lib/formatters.test.ts
@@ -6,6 +6,7 @@ import {
formatCompactUsd,
formatCompactVolume,
formatCurrency,
+ formatCurrencyPrecise,
formatCurrencyPnl,
formatCurrencyVolume,
formatQuoteAmount,
@@ -173,6 +174,23 @@ describe("the minus sign sits before the currency symbol", () => {
});
});
+describe("formatCurrencyPrecise", () => {
+ it("keeps the digits of a sub-cent amount instead of rounding it to zero", () => {
+ // An LP fee of 0.00136 USDC read `$0.00` once converted; the DEX cards
+ // still show `$0.0014`, so the portfolio must not disagree.
+ expect(formatCurrencyPrecise(0.00136)).toBe("$0.0014");
+ expect(formatCurrencyPrecise(-0.00136)).toBe("-$0.0014");
+ expect(formatCurrencyPrecise(0.00136, "€")).toBe("€0.0014");
+ });
+
+ it("is formatCurrency at and above a cent, and for zero", () => {
+ for (const v of [0, 0.01, 1.5, 12.34, 12_345, 2_500_000]) {
+ expect(formatCurrencyPrecise(v)).toBe(formatCurrency(v));
+ expect(formatCurrencyPrecise(-v, "R$")).toBe(formatCurrency(-v, "R$"));
+ }
+ });
+});
+
// ── READ-250: the X axis was hardcoded to HH:MM ──
//
// The sampling interval is derived from the bot's real runtime (PERF-238), so
diff --git a/frontend/src/lib/formatters.ts b/frontend/src/lib/formatters.ts
index 9330877df..840561eae 100644
--- a/frontend/src/lib/formatters.ts
+++ b/frontend/src/lib/formatters.ts
@@ -160,6 +160,18 @@ export function formatCurrency(val: number, symbol = "$") {
});
}
+/**
+ * `formatCurrency` for amounts a cent would round away: an LP fee of `0.00136`
+ * keeps its digits (`$0.0014`) instead of reading `$0.00`. Everything at or
+ * above a cent is unchanged, so only the fee column uses this.
+ */
+export function formatCurrencyPrecise(val: number, symbol = "$") {
+ if (val !== 0 && Math.abs(val) < 0.01) {
+ return withSign(val, (abs) => symbol + abs.toPrecision(2));
+ }
+ return formatCurrency(val, symbol);
+}
+
/**
* Quote assets a `$` renders correctly, mirroring `DOLLAR_QUOTES` in
* `condor/agents/quote_render.py`. The two have to agree: an operator reads the
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..f95936b38 100644
--- a/frontend/src/pages/DexPool.tsx
+++ b/frontend/src/pages/DexPool.tsx
@@ -130,6 +130,9 @@ export function DexPool() {
queryFn: () => api.getDexPoolByAddress(server!, address, network),
enabled: !!server && !!network && !!address,
staleTime: 60 * 1000,
+ // The range bar multiplies by this pool's quote price, so the page must
+ // follow it rather than freeze at the price it was opened on.
+ refetchInterval: 60 * 1000,
});
// Registering the pool's tokens with Gateway is part of opening the pool, not
@@ -351,7 +354,11 @@ export function DexPool() {
);
}
- if (poolError || !pool) {
+ // Only a page with nothing to show falls back to the error view. A failed
+ // refresh keeps the last good pool: the polling timer makes an occasional
+ // rate limit or network blip likely, and trading away an open chart over it
+ // is worse than showing a price one interval old.
+ if (!pool) {
// A pool that could not be *read* is not a pool that does not exist: the
// route answers 503 with a reason when GeckoTerminal refused, and retrying
// in a few seconds is the fix — not walking back to the browser.
@@ -534,6 +541,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..2e821a771 100644
--- a/frontend/src/pages/Portfolio.tsx
+++ b/frontend/src/pages/Portfolio.tsx
@@ -875,11 +875,17 @@ 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,
+ formatFeeValue,
formatPnlValue,
resolvedSymbol: currencySymbol,
} = useRates(quoteCurrencies);
@@ -1084,6 +1090,7 @@ export function Portfolio() {
convert={convert}
formatValue={formatValueDetailed}
formatPnlValue={formatPnlValue}
+ formatFeeValue={formatFeeValue}
/>
|