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
134 changes: 26 additions & 108 deletions frontend/src/pages/Portfolio/components/PerformancePanel.merge.test.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,37 @@
import { describe, expect, it } from "vitest"

import {
chartableEquityPoints,
equityPriceScaleRange,
mergeEquitySeries,
} from "@/pages/Portfolio/components/PerformancePanel"
import { mergeEquitySeries } from "@/pages/Portfolio/components/PerformancePanel"
import type { VenuePerformanceSeries } from "@/services/account-performance"

const venueSeries = (
venue: VenuePerformanceSeries["venue"],
equity_points: VenuePerformanceSeries["equity_points"],
): VenuePerformanceSeries => ({
venue,
equity_points,
events: [],
fetched_at: "2026-01-01T00:00:00Z",
coverage_start_ms: equity_points[0]?.timestamp_ms ?? 0,
coverage_end_ms: equity_points[equity_points.length - 1]?.timestamp_ms ?? 0,
})

describe("mergeEquitySeries", () => {
it("sums overlapping venue samples on the same second", () => {
const hyperliquid = venueSeries("hyperliquid", [
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "110" },
])
const derive = venueSeries("derive", [
{ timestamp_ms: 1_000, value_usd: "50" },
{ timestamp_ms: 3_000, value_usd: "60" },
])

// LOCF: at t=2s Derive still contributes 50; at t=3s HL still contributes 110.
expect(mergeEquitySeries([hyperliquid, derive])).toEqual([
{ timestamp_ms: 1_000, value_usd: "150" },
{ timestamp_ms: 2_000, value_usd: "160" },
{ timestamp_ms: 3_000, value_usd: "170" },
])
})

it("carries the last Hyperliquid balance across Derive-only timestamps", () => {
const hyperliquid = venueSeries("hyperliquid", [
{ timestamp_ms: 1_000, value_usd: "111" },
])
const derive = venueSeries("derive", [
{ timestamp_ms: 2_000, value_usd: "99667" },
{ timestamp_ms: 3_000, value_usd: "99700" },
])
it("forward-fills sparse venues then sums", () => {
const hyperliquid: VenuePerformanceSeries = {
venue: "hyperliquid",
equity_points: [
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 3_000, value_usd: "110" },
],
events: [],
fetched_at: "2026-01-01T00:00:00Z",
coverage_start_ms: 1_000,
coverage_end_ms: 3_000,
}
const derive: VenuePerformanceSeries = {
venue: "derive",
equity_points: [
{ timestamp_ms: 2_000, value_usd: "50" },
{ timestamp_ms: 3_000, value_usd: "60" },
],
events: [],
fetched_at: "2026-01-01T00:00:00Z",
coverage_start_ms: 2_000,
coverage_end_ms: 3_000,
}

expect(mergeEquitySeries([hyperliquid, derive])).toEqual([
{ timestamp_ms: 1_000, value_usd: "111" },
{ timestamp_ms: 2_000, value_usd: "99778" },
{ timestamp_ms: 3_000, value_usd: "99811" },
])
})

it("treats the period before a venue's first sample as zero", () => {
const derive = venueSeries("derive", [
{ timestamp_ms: 5_000, value_usd: "50000" },
])
const hyperliquid = venueSeries("hyperliquid", [
{ timestamp_ms: 1_000, value_usd: "0" },
{ timestamp_ms: 2_000, value_usd: "100" },
])

expect(mergeEquitySeries([hyperliquid, derive])).toEqual([
{ timestamp_ms: 1_000, value_usd: "0" },
{ timestamp_ms: 2_000, value_usd: "100" },
{ timestamp_ms: 5_000, value_usd: "50100" },
])
})

it("skips non-finite and negative venue samples", () => {
const hyperliquid = venueSeries("hyperliquid", [
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "not-a-number" },
{ timestamp_ms: 3_000, value_usd: "-5" },
{ timestamp_ms: 4_000, value_usd: "120" },
])

expect(mergeEquitySeries([hyperliquid])).toEqual([
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 4_000, value_usd: "120" },
{ timestamp_ms: 2_000, value_usd: "150" },
{ timestamp_ms: 3_000, value_usd: "170" },
])
})
})

describe("chartableEquityPoints", () => {
it("keeps only strictly positive finite equity", () => {
expect(
chartableEquityPoints([
{ timestamp_ms: 1, value_usd: "10" },
{ timestamp_ms: 2, value_usd: "0" },
{ timestamp_ms: 3, value_usd: "-1" },
{ timestamp_ms: 4, value_usd: "bad" },
]),
).toEqual([{ timestamp_ms: 1, value_usd: "10" }])
})
})

describe("equityPriceScaleRange", () => {
it("expands a tiny swing to at least five percent of mid", () => {
const range = equityPriceScaleRange([100_000, 100_010])
expect(range).not.toBeNull()
if (range === null) return
const mid = 100_005
const minSpan = mid * 0.05
expect(range.maxValue - range.minValue).toBeGreaterThanOrEqual(
minSpan * 1.1,
)
})

it("ignores zeros when computing the scale", () => {
const range = equityPriceScaleRange([0, 50_000, 50_100])
expect(range).not.toBeNull()
if (range === null) return
expect(range.minValue).toBeGreaterThan(0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from "vitest"

import { twrPercentSeries, underwaterDrawdownSeries } from "./performanceSeries"

const sameInstantFlows = [
{
label: "two deposits",
openingEquity: "100",
closingEquity: "200",
events: [
{ kind: "deposit", amount_usd: "50" },
{ kind: "deposit", amount_usd: "50" },
],
},
{
label: "two withdrawals",
openingEquity: "200",
closingEquity: "100",
events: [
{ kind: "withdraw", amount_usd: "50" },
{ kind: "withdraw", amount_usd: "50" },
],
},
{
label: "offsetting deposit and withdrawal",
openingEquity: "100",
closingEquity: "100",
events: [
{ kind: "deposit", amount_usd: "50" },
{ kind: "withdraw", amount_usd: "50" },
],
},
{
label: "mixed net deposit",
openingEquity: "100",
closingEquity: "150",
events: [
{ kind: "deposit", amount_usd: "70" },
{ kind: "withdraw", amount_usd: "20" },
],
},
] as const

afterEach(() => {
vi.restoreAllMocks()
})

describe.each([
{ metric: "TWR", buildSeries: twrPercentSeries },
{ metric: "drawdown", buildSeries: underwaterDrawdownSeries },
])("$metric cash-flow isolation", ({ buildSeries }) => {
it.each(sameInstantFlows)(
"does not manufacture performance from $label at one valuation",
({ openingEquity, closingEquity, events }) => {
const consoleSpies = (
["debug", "info", "warn", "error", "log", "trace"] as const
).map(level => vi.spyOn(console, level).mockImplementation(() => {}))
const series = buildSeries(
[
{ timestamp_ms: 1_000, value_usd: openingEquity },
{ timestamp_ms: 2_000, value_usd: closingEquity },
],
events.map((event, index) => ({
...event,
timestamp_ms: 2_000,
source_id: `flow-${index}`,
})),
)

expect(series.map(point => point.timestamp_ms)).toEqual([1_000, 2_000])
expect(series[0]?.value).toBeCloseTo(0, 10)
expect(series[1]?.value).toBeCloseTo(0, 10)
consoleSpies.forEach(consoleSpy => {
expect(consoleSpy).not.toHaveBeenCalled()
})
},
)
})
115 changes: 115 additions & 0 deletions frontend/src/pages/Portfolio/components/performanceSeries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest"

import type { VenuePerformanceSeries } from "@/services/account-performance"

import {
filterEquityOutliers,
forwardFillMergeEquity,
resampleEquityPoints,
twrPercentSeries,
underwaterDrawdownSeries,
} from "./performanceSeries"

const venue = (
name: "hyperliquid" | "derive",
points: { timestamp_ms: number; value_usd: string }[],
events: VenuePerformanceSeries["events"] = [],
): VenuePerformanceSeries => ({
venue: name,
equity_points: points,
events,
fetched_at: "2026-01-01T00:00:00Z",
coverage_start_ms: points[0]?.timestamp_ms ?? null,
coverage_end_ms: points[points.length - 1]?.timestamp_ms ?? null,
})

describe("filterEquityOutliers", () => {
it("replaces a 99 percent glitch with the previous value", () => {
const filtered = filterEquityOutliers([
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "0.5" },
{ timestamp_ms: 3_000, value_usd: "101" },
])
expect(filtered).toEqual([
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "100" },
{ timestamp_ms: 3_000, value_usd: "101" },
])
})
})

describe("resampleEquityPoints", () => {
it("keeps the last sample in each hour bucket", () => {
const hour = 3_600_000
const resampled = resampleEquityPoints(
[
{ timestamp_ms: hour + 10, value_usd: "1" },
{ timestamp_ms: hour + 20, value_usd: "2" },
{ timestamp_ms: 2 * hour + 5, value_usd: "3" },
],
hour,
)
expect(resampled).toHaveLength(2)
expect(resampled[0]?.value_usd).toBe("2")
expect(resampled[1]?.value_usd).toBe("3")
})
})

describe("forwardFillMergeEquity", () => {
it("carries sparse venue equity forward before summing", () => {
const merged = forwardFillMergeEquity([
venue("hyperliquid", [
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 3_000, value_usd: "110" },
]),
venue("derive", [
{ timestamp_ms: 2_000, value_usd: "50" },
{ timestamp_ms: 3_000, value_usd: "55" },
]),
])
expect(merged).toEqual([
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "150" },
{ timestamp_ms: 3_000, value_usd: "165" },
])
})
})

describe("twrPercentSeries", () => {
it("does not treat a deposit jump as return", () => {
const series = twrPercentSeries(
[
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "150" },
{ timestamp_ms: 3_000, value_usd: "165" },
],
[
{
kind: "deposit",
timestamp_ms: 2_000,
amount_usd: "50",
source_id: "dep-1",
},
],
)
expect(series[0]?.value).toBeCloseTo(0, 5)
expect(series[1]?.value).toBeCloseTo(0, 5)
expect(series[2]?.value).toBeCloseTo(10, 5)
})
})

describe("underwaterDrawdownSeries", () => {
it("reports drawdown from peak as non-positive percent", () => {
const series = underwaterDrawdownSeries(
[
{ timestamp_ms: 1_000, value_usd: "100" },
{ timestamp_ms: 2_000, value_usd: "120" },
{ timestamp_ms: 3_000, value_usd: "90" },
],
[],
)
expect(series[0]?.value).toBeCloseTo(0, 5)
expect(series[1]?.value).toBeCloseTo(0, 5)
expect(series[2]?.value).toBeCloseTo(-25, 5)
})
})
Loading
Loading