From ec76220ee365fa96ae02a05f477fece4d32ec798 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 12:33:50 +0200 Subject: [PATCH 1/6] fix(web): read an LP position's money in USD, not quote units labelled as dollars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /dex position strip and the portfolio's liquidity rows prefixed every LP money figure with "$", but total_value_quote, fees_earned_quote and net_pnl_quote are denominated in the pair's *quote* — SOL on an X-SOL range. That mislabelled rather than merely rounded the figure: a 0.163 SOL range worth about $20 read as "$0.16". Both /dex surfaces already fetch the pool row, so they hold the quote token's USD price (quote_token_price_usd). When it is known the figure is converted and shown in USD; when it is not, the number is left in quote units under the quote's own symbol rather than a dollar it is not. The portfolio table goes through the page's existing display-currency seam (formatValue / formatPnlValue) so it follows the same rule as every other money cell there. --- frontend/src/components/dex/LpPositionBar.tsx | 18 ++-- frontend/src/components/dex/LpPositions.tsx | 13 +-- .../src/components/dex/lp-position.test.ts | 83 +++++++++++++++++++ frontend/src/components/dex/lp-position.ts | 54 +++++++++++- .../portfolio/PositionsTab.test.tsx | 11 +++ .../src/components/portfolio/PositionsTab.tsx | 14 ++-- frontend/src/hooks/useLpPositions.ts | 16 +++- frontend/src/pages/DexPool.tsx | 1 + frontend/src/pages/Portfolio.tsx | 7 +- 9 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/dex/lp-position.test.ts diff --git a/frontend/src/components/dex/LpPositionBar.tsx b/frontend/src/components/dex/LpPositionBar.tsx index 1c184bc5f..48f3090be 100644 --- a/frontend/src/components/dex/LpPositionBar.tsx +++ b/frontend/src/components/dex/LpPositionBar.tsx @@ -1,20 +1,27 @@ import { useMemo } from "react"; import { - feeAmount, + lpFees, + lpPnl, lpStateStyle, + lpValue, rangeFraction, readLpPosition, type LpPosition, } from "./lp-position"; import { type ExecutorInfo } from "@/lib/api"; -import { formatPnl, formatPriceSig, isExecutorActive, pnlColor } from "@/lib/formatters"; +import { formatPriceSig, isExecutorActive, pnlColor } from "@/lib/formatters"; interface Props { /** The pool's executors, already scoped to this pool by the page. */ executors: ExecutorInfo[]; /** Live pool price, when the chart's own mark is fresher than the executor's. */ currentPrice: number | null; + /** + * The pool's quote-token USD price, so the range's quote-denominated money + * reads in dollars. `null` (an unresolved pool) leaves it in quote units. + */ + quoteUsd?: number | null; selectedExecutorId?: string | null; onSelect?: (executorId: string) => void; } @@ -33,6 +40,7 @@ interface Props { export function LpPositionBar({ executors, currentPrice, + quoteUsd = null, selectedExecutorId, onSelect, }: Props) { @@ -95,16 +103,16 @@ export function LpPositionBar({ {pos.valueQuote !== null && ( value - {feeAmount(pos.valueQuote)} + {lpValue(pos.valueQuote, quoteUsd, pos.quote)} )} {pos.feesQuote !== null && ( fees - {feeAmount(pos.feesQuote)} + {lpFees(pos.feesQuote, quoteUsd, pos.quote)} )} - {formatPnl(pos.pnl)} + {lpPnl(pos.pnl, quoteUsd, pos.quote)} ); diff --git a/frontend/src/components/dex/LpPositions.tsx b/frontend/src/components/dex/LpPositions.tsx index 3a6e4e69e..9ddaf09cc 100644 --- a/frontend/src/components/dex/LpPositions.tsx +++ b/frontend/src/components/dex/LpPositions.tsx @@ -1,9 +1,9 @@ import { ArrowRight } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { feeAmount, lpStateStyle } from "./lp-position"; +import { lpFees, lpPnl, lpStateStyle, lpValue } from "./lp-position"; import { useLpPositions } from "@/hooks/useLpPositions"; -import { formatPnl, formatUsd, pnlColor } from "@/lib/formatters"; +import { pnlColor } from "@/lib/formatters"; /** * The LP positions you already hold, above the pools you might enter. @@ -21,7 +21,7 @@ import { formatPnl, formatUsd, pnlColor } from "@/lib/formatters"; */ export function LpPositions({ server }: { server: string }) { const navigate = useNavigate(); - const { positions, label, dexId } = useLpPositions(server); + const { positions, label, dexId, quoteUsd } = useLpPositions(server); if (!positions.length) return null; @@ -39,6 +39,7 @@ export function LpPositions({ server }: { server: string }) {
{positions.map((pos) => { const state = lpStateStyle(pos.state); + const rate = quoteUsd(pos); return (
{pos.feesQuote !== null && (
- {feeAmount(pos.feesQuote)} fees + {lpFees(pos.feesQuote, rate, pos.quote)} fees
)} diff --git a/frontend/src/components/dex/lp-position.test.ts b/frontend/src/components/dex/lp-position.test.ts new file mode 100644 index 000000000..346ee9858 --- /dev/null +++ b/frontend/src/components/dex/lp-position.test.ts @@ -0,0 +1,83 @@ +/** + * What an LP position's money figures are *denominated in*, and how they render. + * + * The regression this pins: `total_value_quote` / `fees_earned_quote` / + * `net_pnl_quote` are all measured in the pair's quote — SOL on an `X-SOL` range — + * and the cards used to prefix every one of them with a `$`. A 0.163 SOL range + * worth about $20 read as "$0.16": not a rounding error but a wrong number, off + * by the whole SOL/USD rate. The fix prices the figure off the pool's own quote + * token, and when that price is unknown leaves it in SOL under the SOL label. + */ + +import { describe, expect, it } from "vitest"; + +import type { ExecutorInfo } from "@/lib/api"; + +import { lpFees, lpPnl, lpValue, positionQuote, readLpPosition } from "./lp-position"; + +/** The SOL price used across these cases, so the dollars are checkable by hand. */ +const SOL = 121.69; + +function lpExecutor(over: Partial = {}): ExecutorInfo { + return { + id: "e1", + type: "lp_executor", + connector: "solana-mainnet-beta", + trading_pair: "MINT-SOL", + side: "", + status: "running", + close_type: "", + pnl: 0, + volume: 0, + timestamp: 0, + controller_id: "", + cum_fees_quote: 0, + net_pnl_pct: 0, + entry_price: 0, + current_price: 0, + close_timestamp: 0, + custom_info: {}, + config: { pool_address: "POOL", connector_name: "solana-mainnet-beta" }, + ...over, + } as unknown as ExecutorInfo; +} + +describe("positionQuote", () => { + it("reads the quote off the pair and uppercases it", () => { + expect(positionQuote("oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp-SOL")).toBe("SOL"); + expect(positionQuote("So11111111111111111111111111111111111111112-USDC")).toBe("USDC"); + }); + + it("falls back to USDT for a pair with no quote segment", () => { + expect(positionQuote("")).toBe("USDT"); + expect(positionQuote("SOLONLY")).toBe("USDT"); + }); +}); + +describe("readLpPosition", () => { + it("carries the quote the money figures are denominated in", () => { + const pos = readLpPosition(lpExecutor({ trading_pair: "MINT-SOL" })); + expect(pos?.quote).toBe("SOL"); + }); +}); + +describe("LP money in USD when the quote's price is known", () => { + it("prices the position value, PnL and fees in dollars", () => { + expect(lpValue(0.163339, SOL, "SOL")).toBe("$19.88"); + expect(lpPnl(0.003097, SOL, "SOL")).toBe("+$0.38"); + expect(lpFees(0.00136, SOL, "SOL")).toBe("$0.17"); + }); +}); + +describe("LP money with no rate: quote units, not dollars", () => { + it("keeps the figure in SOL and labels it SOL", () => { + expect(lpValue(0.163339, null, "SOL")).toBe("0.1633 SOL"); + expect(lpPnl(-0.00025288, null, "SOL")).toBe("-0.0002529 SOL"); + }); + + it("never stamps a dollar on an unconverted figure", () => { + expect(lpValue(0.163339, null, "SOL")).not.toContain("$"); + expect(lpPnl(0.003, null, "SOL")).not.toContain("$"); + expect(lpFees(2.4e-5, null, "SOL")).not.toContain("$"); + }); +}); diff --git a/frontend/src/components/dex/lp-position.ts b/frontend/src/components/dex/lp-position.ts index acb84b7dc..558fa49ec 100644 --- a/frontend/src/components/dex/lp-position.ts +++ b/frontend/src/components/dex/lp-position.ts @@ -1,5 +1,5 @@ import { type ExecutorInfo } from "@/lib/api"; -import { formatUsd } from "@/lib/formatters"; +import { formatCurrencyPnl, formatUsd } from "@/lib/formatters"; import { num } from "./format"; @@ -28,6 +28,11 @@ function str(v: unknown): string { return typeof v === "string" ? v : ""; } +/** The quote a position's money figures are denominated in — its pair's. */ +export function positionQuote(pair: string): string { + return (pair.split("-")[1] || "USDT").trim().toUpperCase(); +} + /** * One open LP position, flattened out of the executor that holds it. * @@ -43,6 +48,11 @@ export interface LpPosition { poolAddress: string; provider: string; pair: string; + /** + * The quote asset the money figures below are denominated in — the pair's + * quote, which is also what the executor's `*_quote` fields are measured in. + */ + quote: string; /** `IN_RANGE`, `OUT_OF_RANGE`, … as the connector reports it. */ state: string; /** On-chain bounds when the venue reports them, else the requested ones. */ @@ -70,6 +80,7 @@ export function readLpPosition(ex: ExecutorInfo): LpPosition | null { poolAddress, provider: str(config.lp_provider), pair: ex.trading_pair, + quote: positionQuote(ex.trading_pair), state: str(custom.state), lowerPrice: num(custom.lower_price) ?? num(config.lower_price), upperPrice: num(custom.upper_price) ?? num(config.upper_price), @@ -86,6 +97,47 @@ export function feeAmount(val: number): string { return formatUsd(val); } +/** + * The USD price of one unit of a position's quote token, or `null` when it is + * unknown. The /dex surfaces read it off the pool row they already fetch + * (`quote_token_price_usd`), so pricing a range costs no rate lookup of its own. + */ +export type QuoteUsd = number | null; + +/** A quote-denominated amount under the quote's own symbol, rate unknown. */ +function inQuote(amount: number, quote: string): string { + const s = + amount === 0 ? "0" : amount.toLocaleString("en-US", { maximumSignificantDigits: 4 }); + return `${s} ${quote}`; +} + +/** + * An LP money figure in USD when the quote's dollar price is known, else left in + * quote units under the quote's own symbol. + * + * `total_value_quote`, `fees_earned_quote` and `net_pnl_quote` are all + * denominated in the pair's *quote* — SOL on an `X-SOL` range — but the cards + * used to prefix every one of them with a `$`. That mislabelled, rather than + * merely rounded, the whole figure: a 0.163 SOL range worth about $20 read as + * "$0.16". Converting needs the pool's own USD price, which both /dex callers + * already hold; when it is missing the number is left in SOL and *labelled* + * SOL rather than a dollar it is not. + */ +export function lpValue(amount: number, quoteUsd: QuoteUsd, quote: string): string { + return quoteUsd != null && quoteUsd > 0 ? formatUsd(amount * quoteUsd) : inQuote(amount, quote); +} + +export function lpPnl(amount: number, quoteUsd: QuoteUsd, quote: string): string { + if (quoteUsd != null && quoteUsd > 0) return formatCurrencyPnl(amount * quoteUsd); + return (amount >= 0 ? "+" : "") + inQuote(amount, quote); +} + +export function lpFees(amount: number, quoteUsd: QuoteUsd, quote: string): string { + return quoteUsd != null && quoteUsd > 0 + ? feeAmount(amount * quoteUsd) + : inQuote(amount, quote); +} + /** In range is earning; out of range is not, and is the thing worth spotting. */ export function lpStateStyle(state: string): { label: string; diff --git a/frontend/src/components/portfolio/PositionsTab.test.tsx b/frontend/src/components/portfolio/PositionsTab.test.tsx index c19622b97..2fb0a6d6d 100644 --- a/frontend/src/components/portfolio/PositionsTab.test.tsx +++ b/frontend/src/components/portfolio/PositionsTab.test.tsx @@ -97,6 +97,7 @@ function lpPosition(over: Partial = {}): LpPosition { poolAddress: "Pool123456", provider: "meteora", pair: "SOL-USDC", + quote: "USDC", state: "IN_RANGE", lowerPrice: 132.1, upperPrice: 147.9, @@ -268,6 +269,16 @@ 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("$"); + }); }); 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.ts b/frontend/src/hooks/useLpPositions.ts index 517fcbf6c..e8730e08c 100644 --- a/frontend/src/hooks/useLpPositions.ts +++ b/frontend/src/hooks/useLpPositions.ts @@ -68,6 +68,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({ @@ -137,5 +143,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/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, From 0bcd01c364c8eef671947e474656646a728bcba3 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 13:13:38 +0200 Subject: [PATCH 2/6] fix(web): keep a quote's own ticker when it cannot be converted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a quote with no rate path (SOL, ETH, …) the fallback labelled the still-quote-denominated figure `$`, so a 0.163 SOL range read `$0.16` — a dollar it is not. The fallback now keeps the quote's own ticker, suffixed where the /dex surfaces already put it, while a quote the dashboard has a symbol for (or a USD-pegged stablecoin) keeps its prefix. --- .../portfolio/PositionsTab.test.tsx | 24 +++++++++++++++-- frontend/src/lib/rates.test.ts | 14 ++++++++-- frontend/src/lib/rates.ts | 27 ++++++++++++------- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/portfolio/PositionsTab.test.tsx b/frontend/src/components/portfolio/PositionsTab.test.tsx index 2fb0a6d6d..20fea45cf 100644 --- a/frontend/src/components/portfolio/PositionsTab.test.tsx +++ b/frontend/src/components/portfolio/PositionsTab.test.tsx @@ -22,6 +22,8 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConsolidatedPosition, ExecutorInfo } from "@/lib/api"; +import { formatCurrencyPnl, formatCurrencyVolume } from "@/lib/formatters"; +import { formatWithRate } from "@/lib/rates"; import type { LpPosition } from "@/components/dex/lp-position"; import { useLpPositions } from "@/hooks/useLpPositions"; import { PositionsTab } from "./PositionsTab"; @@ -133,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 = {}) { @@ -151,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))} /> , @@ -279,6 +284,21 @@ describe("liquidity", () => { 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/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} ⚠`; }; } From 24a0065401dac803604dda34dbdf4f1bdbddc225 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 13:22:28 +0200 Subject: [PATCH 3/6] fix(web): read an LP portfolio past 30 ranges without dropping prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool query sliced to the first 30 addresses on GeckoTerminal's request cap, so a portfolio of more than 30 ranges left the rest without a pool row — and, now that the /dex cards and the portfolio price in USD, in quote units. The cap is per request, not per portfolio: the backend already splits on the same bound, so the hook chunks each network's addresses into 30-address requests instead of truncating. --- frontend/src/hooks/useLpPositions.test.tsx | 48 ++++++++++++++++++++++ frontend/src/hooks/useLpPositions.ts | 30 ++++++++++---- 2 files changed, 70 insertions(+), 8 deletions(-) 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 e8730e08c..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. @@ -106,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); } @@ -117,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 = {}; From 10a5e8d848e3f611d055819f915905c7a206768a Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Mon, 2 Nov 2026 21:58:16 +0100 Subject: [PATCH 4/6] fix(web): keep small fees and refresh an LP range's USD price Two regressions Greptile caught on the USD conversion: - The portfolio's fee column ran through `formatValue`, whose `$` branch rounds to two decimals, so a 0.00136 USDC fee read `$0.00` while the /dex cards still showed the digits. Fees now convert through `formatCurrencyPrecise`: `formatCurrency` at and above a cent, and the amount's own significant digits below it. - `quoteUsd` read `quote_token_price_usd` off a pool query with no refresh timer, so a /dex page left open kept multiplying by the price from when it was opened. The query now refetches on the positions' own beat (`LP_REFRESH_MS`) instead of only on focus. --- .../portfolio/PositionsTab.test.tsx | 22 ++++++++++++- .../src/components/portfolio/PositionsTab.tsx | 5 ++- frontend/src/hooks/useLpPositions.test.tsx | 32 +++++++++++++++++++ frontend/src/hooks/useLpPositions.ts | 5 ++- frontend/src/hooks/useRates.ts | 14 +++++++- frontend/src/lib/formatters.test.ts | 18 +++++++++++ frontend/src/lib/formatters.ts | 12 +++++++ frontend/src/pages/Portfolio.tsx | 2 ++ 8 files changed, 106 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/portfolio/PositionsTab.test.tsx b/frontend/src/components/portfolio/PositionsTab.test.tsx index 20fea45cf..7dcd95d90 100644 --- a/frontend/src/components/portfolio/PositionsTab.test.tsx +++ b/frontend/src/components/portfolio/PositionsTab.test.tsx @@ -22,7 +22,11 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConsolidatedPosition, ExecutorInfo } from "@/lib/api"; -import { formatCurrencyPnl, formatCurrencyVolume } from "@/lib/formatters"; +import { + formatCurrencyPnl, + formatCurrencyPrecise, + formatCurrencyVolume, +} from "@/lib/formatters"; import { formatWithRate } from "@/lib/rates"; import type { LpPosition } from "@/components/dex/lp-position"; import { useLpPositions } from "@/hooks/useLpPositions"; @@ -138,6 +142,7 @@ interface RenderOpts { /** 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 = {}) { @@ -158,6 +163,7 @@ async function render(opts: RenderOpts = {}) { convert={(value) => ({ value: value / 2, converted: true })} formatValue={opts.formatValue ?? ((val) => fmtValue(val / 2))} formatPnlValue={opts.formatPnlValue ?? ((val) => fmtPnl(val / 2))} + formatFeeValue={opts.formatFeeValue ?? ((val) => fmtValue(val / 2))} /> , @@ -299,6 +305,19 @@ describe("liquidity", () => { 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", () => { @@ -348,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 6fc1fa658..ec3dc36dc 100644 --- a/frontend/src/components/portfolio/PositionsTab.tsx +++ b/frontend/src/components/portfolio/PositionsTab.tsx @@ -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(); @@ -301,7 +304,7 @@ export function PositionsTab({ {pos.feesQuote === null ? "—" - : formatValue(pos.feesQuote, pos.quote)} + : formatFeeValue(pos.feesQuote, pos.quote)} {formatPnlValue(pos.pnl, pos.quote)} diff --git a/frontend/src/hooks/useLpPositions.test.tsx b/frontend/src/hooks/useLpPositions.test.tsx index 5022cca2d..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(); @@ -238,4 +239,35 @@ describe("useLpPositions", () => { // 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 c5d15ab91..35ebcef10 100644 --- a/frontend/src/hooks/useLpPositions.ts +++ b/frontend/src/hooks/useLpPositions.ts @@ -143,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( 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/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx index ecc4d0835..2e821a771 100644 --- a/frontend/src/pages/Portfolio.tsx +++ b/frontend/src/pages/Portfolio.tsx @@ -885,6 +885,7 @@ export function Portfolio() { const { convert, formatValueDetailed, + formatFeeValue, formatPnlValue, resolvedSymbol: currencySymbol, } = useRates(quoteCurrencies); @@ -1089,6 +1090,7 @@ export function Portfolio() { convert={convert} formatValue={formatValueDetailed} formatPnlValue={formatPnlValue} + formatFeeValue={formatFeeValue} /> From cb02f740b60b4105d754b2db48eecc16f1fe4cb6 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Mon, 2 Nov 2026 22:06:52 +0100 Subject: [PATCH 5/6] fix(web): follow a pool page's quote price while it is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The range bar multiplies by `quote_token_price_usd` from the `dex-pool-by-address` query, which had a `staleTime` but no timer — so a page left open kept pricing against the quote at the moment it loaded. Poll it on the same beat as the bins query beside it. --- frontend/src/pages/DexPool.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/pages/DexPool.tsx b/frontend/src/pages/DexPool.tsx index 09654fc35..cf55453c7 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 From 8735b5e805ca5d6a5971280d4dd0454ad6e231c1 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Mon, 2 Nov 2026 22:12:22 +0100 Subject: [PATCH 6/6] fix(web): keep the last good pool when a refresh fails The page refreshes on a timer, so a rate limit or a network blip can land after the pool is already on screen. Guarding on `poolError || !pool` then replaced the chart and trade controls with the error page; only a page with nothing to show should fall back to it. --- frontend/src/pages/DexPool.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/DexPool.tsx b/frontend/src/pages/DexPool.tsx index cf55453c7..f95936b38 100644 --- a/frontend/src/pages/DexPool.tsx +++ b/frontend/src/pages/DexPool.tsx @@ -354,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.