Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions frontend/src/components/dex/LpPositionBar.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -33,6 +40,7 @@ interface Props {
export function LpPositionBar({
executors,
currentPrice,
quoteUsd = null,
selectedExecutorId,
onSelect,
}: Props) {
Expand Down Expand Up @@ -95,16 +103,16 @@ export function LpPositionBar({
{pos.valueQuote !== null && (
<span>
<span className="mr-1 text-[10px] text-[var(--color-text-muted)]">value</span>
{feeAmount(pos.valueQuote)}
{lpValue(pos.valueQuote, quoteUsd, pos.quote)}
</span>
)}
{pos.feesQuote !== null && (
<span>
<span className="mr-1 text-[10px] text-[var(--color-text-muted)]">fees</span>
{feeAmount(pos.feesQuote)}
{lpFees(pos.feesQuote, quoteUsd, pos.quote)}
</span>
)}
<span style={{ color: pnlColor(pos.pnl) }}>{formatPnl(pos.pnl)}</span>
<span style={{ color: pnlColor(pos.pnl) }}>{lpPnl(pos.pnl, quoteUsd, pos.quote)}</span>
</span>
</button>
);
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/components/dex/LpPositions.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;

Expand All @@ -39,6 +39,7 @@ export function LpPositions({ server }: { server: string }) {
<div className="flex gap-2 overflow-x-auto p-3">
{positions.map((pos) => {
const state = lpStateStyle(pos.state);
const rate = quoteUsd(pos);
return (
<button
key={pos.id}
Expand All @@ -65,16 +66,16 @@ export function LpPositions({ server }: { server: string }) {

<div className="flex items-baseline justify-between gap-2 text-xs tabular-nums">
<span>
{pos.valueQuote === null ? "—" : formatUsd(pos.valueQuote)}
{pos.valueQuote === null ? "—" : lpValue(pos.valueQuote, rate, pos.quote)}
</span>
<span style={{ color: pnlColor(pos.pnl) }}>
{formatPnl(pos.pnl)}
{lpPnl(pos.pnl, rate, pos.quote)}
</span>
</div>

{pos.feesQuote !== null && (
<div className="text-[10px] text-[var(--color-text-muted)]">
{feeAmount(pos.feesQuote)} fees
{lpFees(pos.feesQuote, rate, pos.quote)} fees
</div>
)}
</button>
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/components/dex/lp-position.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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("$");
});
});
54 changes: 53 additions & 1 deletion frontend/src/components/dex/lp-position.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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.
*
Expand All @@ -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. */
Expand Down Expand Up @@ -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),
Expand All @@ -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;
Expand Down
35 changes: 33 additions & 2 deletions frontend/src/components/portfolio/PositionsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,6 +99,7 @@ function lpPosition(over: Partial<LpPosition> = {}): LpPosition {
poolAddress: "Pool123456",
provider: "meteora",
pair: "SOL-USDC",
quote: "USDC",
state: "IN_RANGE",
lowerPrice: 132.1,
upperPrice: 147.9,
Expand Down Expand Up @@ -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 = {}) {
Expand All @@ -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))}
/>
</MemoryRouter>
</QueryClientProvider>,
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/components/portfolio/PositionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -294,13 +294,17 @@ export function PositionsTab({
</span>
</td>
<td className={TDR}>
{pos.valueQuote === null ? "—" : formatUsd(pos.valueQuote)}
{pos.valueQuote === null
? "—"
: formatValue(pos.valueQuote, pos.quote)}
</td>
<td className={`${TDR} text-[var(--color-text-muted)]`}>
{pos.feesQuote === null ? "—" : feeAmount(pos.feesQuote)}
{pos.feesQuote === null
? "—"
: formatValue(pos.feesQuote, pos.quote)}
</td>
<td className={TDR} style={{ color: pnlColor(pos.pnl) }}>
{formatPnl(pos.pnl)}
{formatPnlValue(pos.pnl, pos.quote)}
</td>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<td className="px-2 text-right">
<ArrowRight className="h-3.5 w-3.5 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100" />
Expand Down
Loading