import { expect, test } from "bun:test"; import { createTestDataProvider } from "../../../test-support/data-provider"; import { createDefaultConfig } from "../../../types/config"; import { loadChartPaneModel } from "../../../plugins/builtin/chart-composer/headless"; import { buildPriceChartPreset } from "../../../plugins/builtin/chart-composer/presets"; import { formatMarketPriceWithCurrency } from "../../../market-data/market/format"; import { applyResolvedSeriesTransform } from "../../../time-series/transforms"; import type { HeadlessPaneContext } from "../../../types/headless"; import { formatCompositeCursorValue, formatCompositeSeriesValue, seriesPriceReference } from "./format"; import { applyCompositeChartCursor, buildCompositeChartScene, resolveCompositeCursorDate } from "./scene"; import { reuseResolvedSeriesIdentity } from "./panel-series"; import { pricePointsToResolvedSeries } from "./price-series"; const cases = [ ["EURUSD=X", "USD", "CURRENCY", 1.1602274179458618, "$1.160227"], ["USDEUR=X", "EUR", "CURRENCY", 0.8618999719619751, "€0.8619"], // A float32 provider rate: seven significant digits drop the 153.554001 tail. ["JPY=X", "JPY", "CURRENCY", 153.5540008544922, "¥153.554"], ["JPYUSD=X", "USD", "CURRENCY", 0.00651236716657877, "$0.006512"], ["AAPL", "USD", "EQUITY", 259.7499, "$259.75"], // A real provider quote, not a hand-rounded one: crypto display precision has // to survive the float tail rather than spend eight decimals on it. ["BTC-USD", "USD", "CRYPTOCURRENCY", 109_556.1640625, "$109,556.16"], ["ETH-USD", "USD", "CRYPTOCURRENCY", 3_421.8843212, "$3,421.88"], ["SHIB-USD", "USD", "CRYPTOCURRENCY", 0.00000526, "$0.00000526"], ] as const; async function priceModel(symbol: string, currency: string, instrumentType: string, value: number) { const step = Math.min(0.0001, value / 10); // The last completed bar closed where the quote is: a live label takes its // decimals from that close, which a real feed writes like the quote. const points = [value + step, value, value].map((close, offset) => ({ date: new Date(Date.UTC(2026, 8, 9 + offset)), close })); const spec = buildPriceChartPreset(symbol); spec.viewport = { ...spec.viewport, range: "1M", resolution: "1d", dateWindow: { start: "2026-09-09", end: "2026-09-11" } }; spec.studies = [{ id: "sma", kind: "sma", inputSeriesIds: [spec.series[0]!.id], parameters: { period: 2 }, panelId: "main", axis: "left" }]; const provider = createTestDataProvider({ getQuote: async () => ({ symbol, currency, instrumentType, price: value, change: 0, changePercent: 0, lastUpdated: Date.parse("2026-09-11T21:00Z"), stale: true }), getTickerFinancials: async () => ({ annualStatements: [], quarterlyStatements: [], priceHistory: points }), getPriceHistory: async () => points, getPriceHistoryForResolution: async () => points, getDetailedPriceHistory: async () => points, }); const model = await loadChartPaneModel(spec, { marketData: provider, config: createDefaultConfig("/tmp/fx-precision-unused"), apiClient: {} as HeadlessPaneContext["apiClient"], signal: new AbortController().signal, }); return { model, points }; } for (const [symbol, currency, category, value, expected] of cases) { test(`${symbol} keeps its price basis through export, legend, pointer cursor and price study`, async () => { const { model, points } = await priceModel(symbol, currency, category, value); const series = model.chart.series.find((entry) => entry.id === model.spec.series[0]!.id)!; expect(series.points.map((point) => point.value)).toEqual(points.map((point) => point.close)); const exported = JSON.parse(JSON.stringify(model)).series.find((entry: { id: string }) => entry.id === series.id); expect(exported.points.at(-1).value).toBe(value); expect(exported.unit).toBe(series.unit); expect(formatCompositeSeriesValue(value, series)).toBe(expected); expect(formatCompositeSeriesValue(value, exported)).toBe(expected); const scene = buildCompositeChartScene(model.chart.series, model.spec.panels, { width: 101, height: 20 })!; const panel = scene.panels.find((entry) => entry.series.some((item) => item.source.id === series.id))!; const projected = panel.series.find((entry) => entry.source.id === series.id)!.points.at(-1)!; const cursorDate = resolveCompositeCursorDate(scene, projected.xRatio * (scene.width - 1))!; const cursor = applyCompositeChartCursor(scene, cursorDate).cursorValues.find((entry) => entry.seriesId === series.id)!; expect(cursor.value).toBe(value); expect(formatCompositeCursorValue(cursor.value!, panel.axes[series.axis]!)).toBe(expected); const average = model.chart.series.find((entry) => entry.id === "sma")!; const averageValue = average.points.at(-1)!.value!; // On the price's axis, a study of the price is written like the price. expect(formatCompositeSeriesValue(averageValue, average, seriesPriceReference(series))) .toBe(formatCompositeSeriesValue(averageValue, series)); }); } test("shared price axes retain FX cursor precision in either order and metadata recovery updates identical points", async () => { const fx = (await priceModel("EURUSD=X", "USD", "CURRENCY", 1.1602274179458618)).model.chart.series[0]!; const equity = (await priceModel("AAPL", "USD", "EQUITY", 259.7499)).model.chart.series[0]!; for (const entries of [[equity, fx], [fx, equity]]) { const scene = buildCompositeChartScene(entries, [{ id: "main" }], { width: 101, height: 20 })!; expect(formatCompositeCursorValue(1.1602274179458618, scene.panels[0]!.axes.left!)).toBe("$1.160227"); } const untyped = { ...fx, priceAssetCategory: undefined }; const recovered = reuseResolvedSeriesIdentity(untyped, fx); expect(formatCompositeSeriesValue(1.1602274179458618, untyped)).toBe("$1.16"); expect(formatCompositeSeriesValue(1.1602274179458618, recovered)).toBe("$1.160227"); expect(recovered.points).toBe(untyped.points); }); test("FX metadata does not turn normalized returns or ratios into currency prices", async () => { const fx = (await priceModel("EURUSD=X", "USD", "CURRENCY", 1.1602274179458618)).model.chart.series[0]!; const percent = applyResolvedSeriesTransform(fx, "percent"); const latest = percent.points.at(-1)!; expect(latest.value).toBeCloseTo((fx.points.at(-1)!.value! / fx.points[0]!.value! - 1) * 100, 10); expect(latest.rawValue).toBe(fx.points.at(-1)!.value); expect(formatCompositeSeriesValue(latest.value!, percent)).toEndWith("%"); expect(formatCompositeSeriesValue(1.25, { ...fx, unit: "x", unitGroup: "ratio" })).toBe("1.25x"); }); // Live labels read their decimals from the last completed bar, not the start // of the window: a ten-year chart that began under $1 still prints cents. test("a long-range chart labels today's price at today's precision", () => { const closes = [0.0376, 12.5, 180.07, 180.12]; const series = pricePointsToResolvedSeries( closes.map((close, day) => ({ date: new Date(Date.UTC(2016 + day * 3, 0, 1)), close })), { id: "nvda", label: "NVDA", color: "#fff", unit: "USD" }, ); expect(formatCompositeSeriesValue(180.12, series)).toBe("$180.12"); const earnings = { ...series, dataShape: "scalar" as const, unitGroup: "currency-per-share" }; expect(formatCompositeSeriesValue(2.35, earnings)).toBe("$2.35"); });