import { useMemo } from "react"; import { DataTableView, StaticChartSurface, type DataTableCell, type DataTableColumn, type StatItem, } from "../../../components"; import type { ProjectedChartPoint } from "../../../components/chart/core/data"; import { resolveChartPalette } from "../../../components/chart/core/palette"; import type { StaticChartXMarker } from "../../../components/chart/static"; import { colors, priceColor } from "../../../theme/colors"; import { Box } from "../../../ui"; import { displayWidth, formatCurrency, formatNumber } from "../../../utils/format"; import type { KellySizingResult, SensitivityGrid } from "./model"; import { formatPct, formatSignedPct } from "./view"; /** The longest result label ("Full growth"), which sets the band's label column. */ const RESULT_LABEL_CHARS = 11; /** * The sizing result as one band under the inputs, laid out in the FieldGrid's * columns: the Kelly fractions, then the move in money, then risk, then log * growth at full Kelly, at the current size and at the target. */ export function buildKellyResultItems({ result, baseCurrency, currentGrowth, targetGrowth, width, columns, }: { result: KellySizingResult; baseCurrency: string; currentGrowth: number; targetGrowth: number; width: number; columns: number; }): StatItem[] { const addTrim = formatCurrency(result.addTrimValue, baseCurrency); const units = result.estimatedUnits == null ? null : `${formatNumber(result.estimatedUnits, 1)} units`; // The units ride beside the amount when the column has room for label, // amount and units (plus the cell inset and gaps); otherwise they take a // cell of their own rather than being cut off at the column edge. const unitsInline = units != null && Math.floor(width / Math.max(1, columns)) >= RESULT_LABEL_CHARS + displayWidth(addTrim) + displayWidth(units) + 6; return [ { id: "full", label: "Full Kelly", value: formatPct(result.fullKellyFraction, 1) }, { id: "fractional", label: "Fractional", value: formatPct(result.fractionalKellyFraction, 1) }, { id: "clipped", label: "Clipped", value: formatPct(result.clippedFraction, 2), tone: result.clipReasons.length > 0 ? "positive" : undefined, }, { id: "current", label: "Current %", value: formatPct(result.currentFraction, 1) }, { id: "target", label: "Target val", value: formatCurrency(result.targetValue, baseCurrency) }, { id: "addTrim", label: "Add / trim", value: addTrim, detail: unitsInline ? units ?? undefined : undefined, color: priceColor(result.addTrimValue), }, ...(units != null && !unitsInline ? [{ id: "units", label: "Units", value: formatNumber(result.estimatedUnits!, 1) }] : []), { id: "risk", label: "Risk", value: formatCurrency(result.riskValue, baseCurrency), detail: formatPct(result.riskFraction, 2), tone: "negative", }, { id: "worstLoss", label: "Worst loss", value: formatPct(result.downsideLossFraction, 1) }, { id: "expReturn", label: "Exp return", value: formatSignedPct(result.expectedReturn), color: priceColor(result.expectedReturn), }, { id: "fullGrowth", label: "Full growth", value: formatSignedPct(result.expectedLogGrowth) }, { id: "growthNow", label: "Growth now", value: formatSignedPct(currentGrowth) }, { id: "growthTarget", label: "Growth tgt", value: formatSignedPct(targetGrowth) }, ]; } export function KellyCurveSection({ width, height, points, xAxisLabels, curveMaxFraction, markers, focused = false, }: { width: number; height: number; points: ProjectedChartPoint[]; xAxisLabels: string[]; curveMaxFraction: number; markers: StaticChartXMarker[]; focused?: boolean; }) { // The curve takes the rest of the pane; the box absorbs any rounding so the // chart never pushes past the footer. return ( formatSignedPct(value)} xAxisLabels={xAxisLabels} xAxisColor={colors.textDim} formatXAxisCursorValue={(ratio) => formatPct(curveMaxFraction * ratio, 1)} xMarkers={markers} focused={focused} /> ); } interface SensitivityRow { /** Row position: clamped probabilities can repeat a label (99.0% twice). */ id: string; label: string; cells: SensitivityGrid["cells"][number]; } const ROW_AXIS_COLUMN = "row"; const FILL_COLUMN = "fill"; /** * The target size over a small grid of the two main inputs: rows vary the * first (win or estimated probability), columns the second (upside, price). */ export function KellySensitivitySection({ width, height, focused, sensitivity, }: { width: number; height: number; focused: boolean; sensitivity: SensitivityGrid; }) { const columns = useMemo(() => [ { id: ROW_AXIS_COLUMN, label: sensitivity.rowLabel, width: 10, align: "left" }, ...sensitivity.columns.map((column, index) => ({ id: String(index), label: `${sensitivity.columnLabel} ${column}`, width: Math.max(10, sensitivity.columnLabel.length + column.length + 1), align: "right" as const, })), // A small matrix reads best packed beside its row axis; the empty column // takes the rest of the width so the header band still spans the pane. { id: FILL_COLUMN, label: "", width: 1, flexGrow: 1, align: "left" }, ], [sensitivity.columnLabel, sensitivity.columns, sensitivity.rowLabel]); const rows = useMemo( () => sensitivity.rows.map((label, index) => ({ id: String(index), label, cells: sensitivity.cells[index] ?? [] })), [sensitivity.cells, sensitivity.rows], ); const renderCell = (row: SensitivityRow, column: DataTableColumn): DataTableCell => { if (column.id === ROW_AXIS_COLUMN) return { text: row.label, color: colors.textDim }; if (column.id === FILL_COLUMN) return { text: "" }; return { text: row.cells[Number(column.id)]?.text ?? "—" }; }; return ( columns={columns} items={rows} getItemKey={(row) => row.id} selection={{ kind: "none" }} focused={focused} keyboardNavigation={false} sortColumnId={null} sortDirection="asc" renderCell={renderCell} rootWidth={width} rootHeight={Math.max(2, height)} emptyStateTitle="No sensitivity grid" /> ); }