Skip to content
Merged
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
73 changes: 73 additions & 0 deletions ui/src/components/Metric.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ReactNode } from 'react'

export type MetricSize = 'sm' | 'md' | 'lg'
export type MetricSign = 'up' | 'down' | 'flat'

export interface MetricDelta {
/** Pre-formatted display string, e.g. "+$201.40 (+0.84%)". */
value: string
sign: MetricSign
}

interface MetricProps {
label: string
value: ReactNode
delta?: MetricDelta
/** Color the value itself by sign — for PnL metrics. Falls back to neutral text. */
valueSign?: MetricSign
size?: MetricSize
className?: string
}

/**
* Label + big-number + optional delta block. Replaces the per-page inline
* `Metric` components in UTADetailPage / SnapshotDetail / etc. Sign-driven
* color logic (green up, red down, neutral flat) lives in one place so
* the visual contract is consistent.
*
* Sizes:
* sm — secondary metrics row (Cash, Buying Power, etc.). 16px value.
* md — card-level metric (UTA card NLV). 22px value.
* lg — page hero (UTA detail page NLV). 28→36px responsive.
*/
export function Metric({ label, value, delta, valueSign, size = 'md', className }: MetricProps) {
const valueClass = (() => {
const color = signColor(valueSign)
switch (size) {
case 'sm': return `text-[16px] font-semibold tabular-nums ${color}`
case 'lg': return `text-[28px] md:text-[36px] font-bold tabular-nums leading-tight ${color}`
case 'md':
default: return `text-[22px] font-bold tabular-nums ${color}`
}
})()

return (
<div className={className}>
<p className="text-[11px] text-text-muted uppercase tracking-wide">{label}</p>
<p className={valueClass}>{value}</p>
{delta && (
<p className={`text-[12px] tabular-nums mt-0.5 ${signColor(delta.sign)}`}>
{arrowFor(delta.sign)} {delta.value}
</p>
)}
</div>
)
}

function signColor(sign?: MetricSign): string {
if (sign === 'up') return 'text-green'
if (sign === 'down') return 'text-red'
return 'text-text'
}

function arrowFor(sign: MetricSign): string {
if (sign === 'up') return '▲'
if (sign === 'down') return '▼'
return '·'
}

/** Pick a sign from a numeric delta. `flat` for `0` (or NaN). */
export function signFromDelta(n: number | null | undefined): MetricSign {
if (n == null || !Number.isFinite(n) || n === 0) return 'flat'
return n > 0 ? 'up' : 'down'
}
82 changes: 82 additions & 0 deletions ui/src/components/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useMemo } from 'react'
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts'

type SparklineColor = 'green' | 'red' | 'accent' | 'auto'

interface SparklineProps {
values: number[]
/** `auto` derives green/red from sign of (last - first); fallback `accent`. */
color?: SparklineColor
height?: number
/** Width is fluid by default; set to fix the chart at a specific size. */
width?: number
className?: string
}

/**
* Compact area chart for in-card "trend at a glance" rendering. No axes,
* no tooltip, no legend — pure visual cue. Use `<Sparkline values={...} />`
* inside a sized container; pass `width` only when the parent doesn't
* provide an intrinsic size (recharts ResponsiveContainer needs one or
* the other).
*
* Empty / single-point series renders nothing — the caller decides what
* to show in that slot (microcopy, a dash, etc.).
*/
export function Sparkline({
values,
color = 'auto',
height = 28,
width,
className,
}: SparklineProps) {
const data = useMemo(() => values.map((v, i) => ({ i, v })), [values])

const stroke = useMemo(() => {
if (color === 'green') return 'var(--color-green)'
if (color === 'red') return 'var(--color-red)'
if (color === 'accent') return 'var(--color-accent)'
if (values.length < 2) return 'var(--color-accent)'
return values[values.length - 1] >= values[0]
? 'var(--color-green)'
: 'var(--color-red)'
}, [color, values])

if (values.length < 2) return null

// Unique gradient id per render so multiple sparklines in one tree don't
// clobber each other's <linearGradient> defs.
const gradId = useMemo(
() => `sparkline-grad-${Math.random().toString(36).slice(2, 9)}`,
[],
)

const containerStyle = width != null
? { width, height }
: { width: '100%', height }

return (
<div style={containerStyle} className={className}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 1, right: 0, bottom: 1, left: 0 }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={0.35} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={['dataMin', 'dataMax']} />
<Area
type="monotone"
dataKey="v"
stroke={stroke}
strokeWidth={1.25}
fill={`url(#${gradId})`}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}
64 changes: 64 additions & 0 deletions ui/src/lib/asset-class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Maps `Position.contract.secType` (broker-supplied, varies by adapter)
* to a display class for grouping in the positions table.
*
* IBKR's secType taxonomy ("STK" / "OPT" / "CASH" / "FUT" / etc.) is the
* superset reference; CCXT and others normalize down to a smaller set.
* We classify defensively — anything unrecognised falls into `other`
* rather than guessing, so a new broker's quirk surfaces as a visible
* "Other" group rather than getting silently lumped under Equity.
*/

export type AssetClass = 'equity' | 'option' | 'crypto' | 'forex' | 'future' | 'bond' | 'etf' | 'other'

const SECTYPE_TO_CLASS: Record<string, AssetClass> = {
// IBKR canonical
STK: 'equity',
OPT: 'option',
FUT: 'future',
CASH: 'forex', // IBKR uses CASH for forex pairs
BOND: 'bond',
// Common variants
STOCK: 'equity',
OPTION: 'option',
FUTURE: 'future',
FX: 'forex',
FOREX: 'forex',
CRYPTO: 'crypto',
SPOT: 'crypto', // CCXT-style spot
PERP: 'future', // CCXT-style perpetual
SWAP: 'future',
ETF: 'etf',
}

export function secTypeToClass(secType?: string): AssetClass {
if (!secType) return 'other'
return SECTYPE_TO_CLASS[secType.toUpperCase()] ?? 'other'
}

const LABELS: Record<AssetClass, string> = {
equity: 'Equity',
option: 'Option',
crypto: 'Crypto',
future: 'Futures',
forex: 'Forex',
bond: 'Bond',
etf: 'ETF',
other: 'Other',
}

export function assetClassLabel(c: AssetClass): string {
return LABELS[c]
}

/** Stable display order for grouped positions. */
export const ASSET_CLASS_ORDER: readonly AssetClass[] = [
'equity',
'etf',
'crypto',
'option',
'future',
'forex',
'bond',
'other',
]
69 changes: 69 additions & 0 deletions ui/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Money / number formatting helpers shared across trading + portfolio surfaces.
*
* UTADetailPage, SnapshotDetail, and the new UTA cards all need the same
* "$1,234.56" / "+$1,234.56" / native-currency-symbol behaviour. Keep the
* canonical implementations here so a stylistic change (decimal places,
* compact notation) is one edit, not ten.
*
* Note: these accept either `number` or numeric `string` (the trading API
* serializes `Decimal` as string to avoid IEEE-754 rounding artifacts in
* money math). NaN / non-finite inputs render as "—" — loud, never silent.
*/

export const CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$', HKD: 'HK$', EUR: '€', GBP: '£', JPY: '¥',
CNY: '¥', CNH: '¥', CAD: 'C$', AUD: 'A$', CHF: 'CHF ',
SGD: 'S$', KRW: '₩', INR: '₹', TWD: 'NT$', BRL: 'R$',
}

export function currencySymbol(currency?: string): string {
if (!currency) return '$'
return CURRENCY_SYMBOLS[currency.toUpperCase()] ?? `${currency} `
}

function toFiniteNumber(input: number | string | undefined | null): number | null {
if (input == null) return null
const n = typeof input === 'number' ? input : Number(input)
return Number.isFinite(n) ? n : null
}

/** "$1,234.56" / "HK$1,234.56" / "—" for NaN. */
export function fmt(input: number | string | undefined | null, currency?: string): string {
const n = toFiniteNumber(input)
if (n == null) return '—'
const sym = currencySymbol(currency)
return `${sym}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}

/** "+$1,234.56" / "-$1,234.56". `0` renders as "+$0.00" (intentional —
* matches the rest of the stack; a "flat" badge is the caller's job). */
export function fmtPnl(input: number | string | undefined | null, currency?: string): string {
const n = toFiniteNumber(input)
if (n == null) return '—'
const sym = currencySymbol(currency)
const sign = n >= 0 ? '+' : '-'
const abs = Math.abs(n)
return `${sign}${sym}${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}

/** "1,234.56" or "0.0123" for sub-unit values; for share/contract counts. */
export function fmtNum(input: number | string | undefined | null): string {
const n = toFiniteNumber(input)
if (n == null) return '—'
return Math.abs(n) >= 1
? n.toLocaleString('en-US', { maximumFractionDigits: 4 })
: n.toPrecision(4)
}

/** "+1.92%" / "-0.34%". `0` renders as "+0.00%". */
export function fmtPctSigned(pct: number | undefined | null, digits = 2): string {
if (pct == null || !Number.isFinite(pct)) return '—'
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(digits)}%`
}

// Re-export the Market workbench's helpers from one place so callers only
// need to import from `lib/format`. The split was historical (market/format
// predates this lib); consolidating now.
export { fmtNumber, fmtInt, fmtMoneyShort, fmtPercent } from '../components/market/format'
Loading
Loading