From b0b79ba4175467feca17166f70c29f2579f35e73 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 9 May 2026 13:45:45 +0800 Subject: [PATCH] feat(ui/uta): redesign Trading list + UTA detail with live data, NAV chart, grouped positions, snapshot timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages were sitting on richer data than they used: - api.trading.equity() — USD totals + per-UTA breakdown (no polling) - api.trading.equityCurve() — time-bucketed NAV with per-UTA points - snapshots() already polled at 60s on detail page Trading list: - Hero banner: total NLV (USD) + 24h delta + cash + unrealized - UTA cards: live NLV, today PnL with arrow, cash, 24h sparkline - 30s polling on aggregates (equity + equityCurve) UTA detail: - Hero with 28-36px NLV + today PnL arrow + 4 secondary metrics - Reused EquityCurve in single-account mode for the NAV chart - Positions grouped by asset class (Equity / Crypto / Option / etc.) with per-group subtotals; secType→class via new asset-class helper, unrecognised types fall to Other rather than guessing - Avg→Mark column collapses two old columns into a directional read - Snapshots-as-timeline with sticky date dividers + delta vs previous; click expands existing SnapshotDetail inline - Bumped snapshot fetch from 20 → 50 entries New primitives: - ui/src/components/Sparkline.tsx — recharts AreaChart, no axes, color auto-derived from trend - ui/src/components/Metric.tsx — label/value/delta with sm/md/lg sizes, consolidates per-page inline copies - ui/src/lib/format.ts — fmt/fmtPnl/fmtNum/fmtPctSigned/currencySymbol consolidated; re-exports market/format helpers - ui/src/lib/asset-class.ts — secTypeToClass + ordering for grouping No backend changes; every visual feeds an existing endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/Metric.tsx | 73 +++++ ui/src/components/Sparkline.tsx | 82 +++++ ui/src/lib/asset-class.ts | 64 ++++ ui/src/lib/format.ts | 69 ++++ ui/src/pages/TradingPage.tsx | 243 ++++++++++++-- ui/src/pages/UTADetailPage.tsx | 560 +++++++++++++++++++++----------- 6 files changed, 869 insertions(+), 222 deletions(-) create mode 100644 ui/src/components/Metric.tsx create mode 100644 ui/src/components/Sparkline.tsx create mode 100644 ui/src/lib/asset-class.ts create mode 100644 ui/src/lib/format.ts diff --git a/ui/src/components/Metric.tsx b/ui/src/components/Metric.tsx new file mode 100644 index 00000000..cadeb6b1 --- /dev/null +++ b/ui/src/components/Metric.tsx @@ -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 ( +
+

{label}

+

{value}

+ {delta && ( +

+ {arrowFor(delta.sign)} {delta.value} +

+ )} +
+ ) +} + +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' +} diff --git a/ui/src/components/Sparkline.tsx b/ui/src/components/Sparkline.tsx new file mode 100644 index 00000000..f29547ad --- /dev/null +++ b/ui/src/components/Sparkline.tsx @@ -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 `` + * 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 defs. + const gradId = useMemo( + () => `sparkline-grad-${Math.random().toString(36).slice(2, 9)}`, + [], + ) + + const containerStyle = width != null + ? { width, height } + : { width: '100%', height } + + return ( +
+ + + + + + + + + + + + +
+ ) +} diff --git a/ui/src/lib/asset-class.ts b/ui/src/lib/asset-class.ts new file mode 100644 index 00000000..3ec4ff05 --- /dev/null +++ b/ui/src/lib/asset-class.ts @@ -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 = { + // 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 = { + 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', +] diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 00000000..8ec5f2a0 --- /dev/null +++ b/ui/src/lib/format.ts @@ -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 = { + 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' diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index bd190a67..173c5cc5 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { Field, inputClass } from '../components/form' import { SDKSelector } from '../components/SDKSelector' @@ -10,8 +10,80 @@ import { PageHeader } from '../components/PageHeader' import { Dialog } from '../components/uta/Dialog' import { HealthBadge } from '../components/uta/HealthBadge' import { SchemaFormFields } from '../components/uta/SchemaFormFields' +import { Metric, signFromDelta } from '../components/Metric' +import { Sparkline } from '../components/Sparkline' +import { fmt, fmtPnl, fmtPctSigned } from '../lib/format' import { api } from '../api' -import type { UTAConfig, BrokerPreset, BrokerHealthInfo, TestConnectionResult, Position, AccountInfo } from '../api/types' +import type { UTAConfig, BrokerPreset, BrokerHealthInfo, TestConnectionResult, Position, AccountInfo, EquityCurvePoint } from '../api/types' + +// ==================== Live equity (across all UTAs) ==================== + +interface EquitySummary { + totalEquity: string + totalCash: string + totalUnrealizedPnL: string + totalRealizedPnL: string + accounts: Array<{ id: string; label: string; equity: string; cash: string }> +} + +interface PerUtaCurve { values: number[]; firstAtCutoff: number | null; latest: number | null } + +interface CurveSummary { + /** Aggregate (across all UTAs) — feeds the hero banner. */ + total: { values: number[]; firstAtCutoff: number | null; latest: number | null } + /** Per-UTA curves — feed the per-card sparkline + 24h delta. */ + perUta: Record +} + +const CUTOFF_24H_MS = 24 * 60 * 60 * 1000 + +/** Build a curve summary from equity-curve points: latest value + the + * oldest value still within the trailing 24h window (the "baseline" + * for today PnL). */ +function summarizeCurve(points: EquityCurvePoint[]): CurveSummary { + const sorted = [...points].sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + const cutoff = Date.now() - CUTOFF_24H_MS + + const totalValues: number[] = [] + let totalFirstAtCutoff: number | null = null + let totalLatest: number | null = null + const perUtaValues = new Map() + const perUtaFirstAtCutoff = new Map() + const perUtaLatest = new Map() + + for (const p of sorted) { + const t = new Date(p.timestamp).getTime() + const totalN = Number(p.equity) + if (Number.isFinite(totalN)) { + totalValues.push(totalN) + totalLatest = totalN + if (t >= cutoff && totalFirstAtCutoff == null) totalFirstAtCutoff = totalN + } + for (const [id, raw] of Object.entries(p.accounts ?? {})) { + const n = Number(raw) + if (!Number.isFinite(n)) continue + let arr = perUtaValues.get(id) + if (!arr) { arr = []; perUtaValues.set(id, arr) } + arr.push(n) + perUtaLatest.set(id, n) + if (t >= cutoff && !perUtaFirstAtCutoff.has(id)) perUtaFirstAtCutoff.set(id, n) + } + } + + const perUta: Record = {} + for (const [id, values] of perUtaValues) { + perUta[id] = { + values, + firstAtCutoff: perUtaFirstAtCutoff.get(id) ?? null, + latest: perUtaLatest.get(id) ?? null, + } + } + + return { + total: { values: totalValues, firstAtCutoff: totalFirstAtCutoff, latest: totalLatest }, + perUta, + } +} // ==================== Page ==================== @@ -21,11 +93,37 @@ export function TradingPage() { const navigate = useNavigate() const [showAdd, setShowAdd] = useState(false) const [presets, setPresets] = useState([]) + const [equity, setEquity] = useState(null) + const [curve, setCurve] = useState(null) useEffect(() => { api.trading.getBrokerPresets().then(r => setPresets(r.presets)).catch(() => {}) }, []) + // Live aggregates: pull `equity()` for headline numbers and `equityCurve()` + // for trend + 24h delta. One fetch each per cycle, shared across the + // hero banner + every UTA card. Polling cadence (30s) is informational — + // user can drill into a UTA for the 15s refresh of broker state. + const refreshAggregates = useCallback(async () => { + try { + const [eq, cv] = await Promise.all([ + api.trading.equity().catch(() => null), + api.trading.equityCurve({ limit: 1500 }).catch(() => ({ points: [] as EquityCurvePoint[] })), + ]) + if (eq) setEquity(eq) + setCurve(summarizeCurve(cv.points)) + } catch { + // Don't surface — aggregates are nice-to-have, the page still renders + // from useTradingConfig if the equity endpoint is down. + } + }, []) + + useEffect(() => { + refreshAggregates() + const id = setInterval(refreshAggregates, 30_000) + return () => clearInterval(id) + }, [refreshAggregates]) + if (tc.loading) return if (tc.error) { return ( @@ -41,26 +139,35 @@ export function TradingPage() {
-
+
{tc.utas.length === 0 ? ( setShowAdd(true)} /> ) : ( <> - {tc.utas.map((uta) => ( - p.id === uta.presetId)} - health={healthMap[uta.id]} - onClick={() => navigate(`/uta/${uta.id}`)} - /> - ))} - + {equity && } + +
+ {tc.utas.map((uta) => { + const equityRow = equity?.accounts.find(a => a.id === uta.id) ?? null + return ( + p.id === uta.presetId)} + health={healthMap[uta.id]} + equity={equityRow} + curve={curve?.perUta[uta.id] ?? null} + onClick={() => navigate(`/uta/${uta.id}`)} + /> + ) + })} + +
)}
@@ -76,6 +183,8 @@ export function TradingPage() { throw new Error(result.error || 'Connection failed') } setShowAdd(false) + // Trigger a fresh fetch so the new UTA shows live numbers right away. + void refreshAggregates() return created }} onOpenExisting={(id) => { @@ -116,6 +225,50 @@ function EmptyState({ onAdd }: { onAdd: () => void }) { ) } +// ==================== Portfolio banner (hero) ==================== + +function PortfolioBanner({ equity, curve }: { + equity: EquitySummary + curve: { values: number[]; firstAtCutoff: number | null; latest: number | null } | null +}) { + const total = Number(equity.totalEquity) + const cash = Number(equity.totalCash) + const unrealized = Number(equity.totalUnrealizedPnL) + + // 24h delta from the curve summary. If curve is empty or the cutoff + // baseline isn't available (UTA freshly added), suppress the delta. + let deltaNode: React.ReactNode = null + if (curve && curve.latest != null && curve.firstAtCutoff != null) { + const delta = curve.latest - curve.firstAtCutoff + const pct = curve.firstAtCutoff !== 0 ? (delta / curve.firstAtCutoff) * 100 : 0 + const sign = signFromDelta(delta) + const arrow = sign === 'up' ? '▲' : sign === 'down' ? '▼' : '·' + const color = sign === 'up' ? 'text-green' : sign === 'down' ? 'text-red' : 'text-text-muted' + deltaNode = ( + + {arrow} {fmtPnl(delta, 'USD')} ({fmtPctSigned(pct)}) today + + ) + } + + return ( +
+

Total Portfolio · USD

+
+ + {fmt(total, 'USD')} + + {deltaNode} +
+
+ Cash {fmt(cash, 'USD')} + · + Unrealized = 0 ? 'text-green' : 'text-red'}`}>{fmtPnl(unrealized, 'USD')} +
+
+ ) +} + // ==================== Subtitle builder ==================== function buildSubtitle(uta: UTAConfig, preset?: BrokerPreset): string { @@ -141,10 +294,12 @@ function buildSubtitle(uta: UTAConfig, preset?: BrokerPreset): string { // ==================== UTA Card ==================== -function UTACard({ uta, preset, health, onClick }: { +function UTACard({ uta, preset, health, equity, curve, onClick }: { uta: UTAConfig preset?: BrokerPreset health?: BrokerHealthInfo + equity?: { equity: string; cash: string } | null + curve?: PerUtaCurve | null onClick: () => void }) { const isDisabled = health?.disabled || uta.enabled === false @@ -152,20 +307,33 @@ function UTACard({ uta, preset, health, onClick }: { ? { text: preset.badge, color: `${preset.badgeColor} ${preset.badgeColor.replace('text-', 'bg-')}/10` } : { text: uta.presetId.slice(0, 2).toUpperCase(), color: 'text-text-muted bg-text-muted/10' } + // 24h delta for this UTA. + const delta = curve && curve.latest != null && curve.firstAtCutoff != null + ? { value: curve.latest - curve.firstAtCutoff, pct: curve.firstAtCutoff !== 0 ? ((curve.latest - curve.firstAtCutoff) / curve.firstAtCutoff) * 100 : 0 } + : null + + const sparkValues = curve?.values ?? [] + const showSpark = !isDisabled && sparkValues.length >= 2 + + const equityNum = equity ? Number(equity.equity) : null + const cashNum = equity ? Number(equity.cash) : null + return ( ) } diff --git a/ui/src/pages/UTADetailPage.tsx b/ui/src/pages/UTADetailPage.tsx index 172f2f56..d1a160f7 100644 --- a/ui/src/pages/UTADetailPage.tsx +++ b/ui/src/pages/UTADetailPage.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { Fragment, useState, useEffect, useCallback, useMemo } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import type { ViewSpec } from '../tabs/types' import { api } from '../api' -import type { UTAConfig, BrokerPreset, AccountInfo, Position, BrokerHealthInfo, UTASnapshotSummary } from '../api/types' +import type { UTAConfig, BrokerPreset, AccountInfo, Position, BrokerHealthInfo, UTASnapshotSummary, EquityCurvePoint } from '../api/types' import { useTradingConfig } from '../hooks/useTradingConfig' import { useAccountHealth } from '../hooks/useAccountHealth' import { PageHeader } from '../components/PageHeader' @@ -13,6 +13,10 @@ import { HealthBadge } from '../components/uta/HealthBadge' import { EditUTADialog } from '../components/uta/EditUTADialog' import { OrderEntryDialog, type OrderEntryMode } from '../components/uta/OrderEntryDialog' import { SnapshotDetail } from '../components/SnapshotDetail' +import { EquityCurve } from '../components/EquityCurve' +import { Metric, signFromDelta } from '../components/Metric' +import { fmt, fmtPnl, fmtNum, fmtPctSigned } from '../lib/format' +import { secTypeToClass, assetClassLabel, ASSET_CLASS_ORDER, type AssetClass } from '../lib/asset-class' // ==================== Page ==================== @@ -31,12 +35,11 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { const [positions, setPositions] = useState([]) const [orders, setOrders] = useState([]) const [snapshots, setSnapshots] = useState([]) - const [selectedSnapshot, setSelectedSnapshot] = useState(null) const [editing, setEditing] = useState(false) const [orderMode, setOrderMode] = useState(null) const [dataError, setDataError] = useState(null) + const [expandedSnapshot, setExpandedSnapshot] = useState(null) - // Preset metadata (stable across renders) useEffect(() => { api.trading.getBrokerPresets().then(r => setPresets(r.presets)).catch(() => {}) }, []) @@ -45,7 +48,7 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { const preset = useMemo(() => presets.find(p => p.id === uta?.presetId), [presets, uta]) const health: BrokerHealthInfo | undefined = id ? healthMap[id] : undefined - // Active polling — account/positions/orders refresh every 15s + // Live polling — account/positions/orders refresh every 15s. const refreshLive = useCallback(async () => { if (!id) return setDataError(null) @@ -63,14 +66,15 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { } }, [id]) - // Snapshots refresh more slowly (60s) + // Snapshots refresh more slowly (60s); same data feeds the NAV chart and + // the 24h-delta anchor — no extra fetches needed. const refreshSnapshots = useCallback(async () => { if (!id) return try { - const r = await api.trading.snapshots(id, { limit: 20 }) + const r = await api.trading.snapshots(id, { limit: 50 }) setSnapshots(r.snapshots) } catch { - // non-fatal — snapshots are secondary content + // non-fatal } }, [id]) @@ -82,21 +86,55 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { return () => { clearInterval(liveInterval); clearInterval(snapshotInterval) } }, [refreshLive, refreshSnapshots]) - // URL query param `?aliceId=...` (e.g. clicked from - // TradeableContractsPanel) auto-opens the place-order form prefilled. + // ?aliceId=... auto-opens the place-order form prefilled (e.g. clicked + // from TradeableContractsPanel on the market workbench). useEffect(() => { const queryAlice = searchParams.get('aliceId') if (queryAlice && !orderMode) { setOrderMode({ kind: 'place', aliceId: queryAlice }) - // Clear the param so back/forward + reopen behave sensibly. const next = new URLSearchParams(searchParams) next.delete('aliceId') setSearchParams(next, { replace: true }) } }, [searchParams, setSearchParams, orderMode]) - if (tc.loading) return + // 24h delta = current NLV − the oldest snapshot still within the trailing + // 24h window. We label this "today" in the UI even though it's strictly + // 24h-trailing — matches consumer-trading apps' "Day's Change" wording + // without entangling market-hours / timezone arithmetic. + const todayDelta = useMemo(() => { + if (!account || snapshots.length === 0) return null + const cutoff = Date.now() - 24 * 60 * 60 * 1000 + let baseline: number | null = null + for (let i = snapshots.length - 1; i >= 0; i--) { + const t = new Date(snapshots[i].timestamp).getTime() + if (t >= cutoff) { + baseline = Number(snapshots[i].account.netLiquidation) + break + } + } + if (baseline == null || !Number.isFinite(baseline)) return null + const current = Number(account.netLiquidation) + if (!Number.isFinite(current)) return null + const delta = current - baseline + const pct = baseline === 0 ? 0 : (delta / baseline) * 100 + return { delta, pct, currency: account.baseCurrency } + }, [account, snapshots]) + + // Snapshots → EquityCurvePoint[] for the chart. Sorted ascending so the + // chart renders left-to-right oldest-to-newest (recharts convention). + const curvePoints = useMemo(() => { + if (!id || snapshots.length === 0) return [] + return [...snapshots] + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + .map(s => ({ + timestamp: s.timestamp, + equity: s.account.netLiquidation, + accounts: { [id]: s.account.netLiquidation }, + })) + }, [snapshots, id]) + if (tc.loading) return if (!id) return if (!uta) { return ( @@ -116,7 +154,6 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { return (
- {/* Header */} + Place Order -
@@ -150,35 +187,40 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { />
-
+
{dataError && (
Failed to load live data: {dataError}
)} - + - {positions.length > 0 ? ( - setOrderMode({ - kind: 'close', - aliceId: p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '', - quantity: p.quantity, - symbol: p.contract.symbol, - })} + {curvePoints.length >= 2 && ( + { /* single-account mode: switcher hidden */ }} /> - ) : ( - )} + setOrderMode({ + kind: 'close', + aliceId: p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '', + quantity: p.quantity, + symbol: p.contract.symbol, + })} + /> + - setExpandedSnapshot(prev => prev === ts ? null : ts)} />
@@ -203,8 +245,6 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { utaId={uta.id} mode={orderMode} onClose={() => setOrderMode(null)} - // Trigger an immediate refresh so the new order/position - // shows up without waiting for the 15s polling tick. onPushComplete={() => { void refreshLive() }} /> )} @@ -212,7 +252,7 @@ export function UTADetailPage({ spec }: UTADetailPageProps) { ) } -// ==================== Shell (loading / error states) ==================== +// ==================== Shell ==================== function Shell({ title, children }: { title: string; children?: React.ReactNode }) { return ( @@ -225,109 +265,195 @@ function Shell({ title, children }: { title: string; children?: React.ReactNode ) } -// ==================== Hero Metrics ==================== +// ==================== Hero ==================== + +interface TodayDelta { delta: number; pct: number; currency: string } -function HeroMetrics({ account }: { account: AccountInfo | null }) { +function Hero({ account, todayDelta }: { account: AccountInfo | null; todayDelta: TodayDelta | null }) { if (!account) { return ( -
+

Loading account info…

) } const ccy = account.baseCurrency || 'USD' + const unrealized = Number(account.unrealizedPnL) + const realized = Number(account.realizedPnL ?? '0') + return ( -
-
- - - - +
+ +
+ + + +
) } -function Metric({ label, value, pnl }: { label: string; value: string; pnl?: number }) { - const color = pnl == null ? 'text-text' : pnl >= 0 ? 'text-green' : 'text-red' +// ==================== Section helper ==================== + +function Section({ title, action, children }: { title: string; action?: React.ReactNode; children: React.ReactNode }) { return ( -
-

{label}

-

{value}

-
+
+
+

{title}

+ {action} +
+ {children} +
) } -// ==================== Positions Table ==================== +// ==================== Positions (grouped by asset class) ==================== + +interface PositionGroup { class: AssetClass; positions: Position[] } -function PositionsTable({ positions, onCloseClick }: { +function PositionsSection({ positions, onCloseClick }: { positions: Position[] onCloseClick: (p: Position) => void }) { + const groups = useMemo(() => { + const buckets = new Map() + for (const p of positions) { + const c = secTypeToClass(p.contract.secType) + if (!buckets.has(c)) buckets.set(c, []) + buckets.get(c)!.push(p) + } + return ASSET_CLASS_ORDER + .filter(c => buckets.has(c)) + .map(c => ({ class: c, positions: buckets.get(c)! })) + }, [positions]) + + if (positions.length === 0) { + return ( +
+
+ No open positions. +
+
+ ) + } + + const cols = 7 // contract, side, qty, avg→mark, value, pnl, action + return ( -
-

- Positions ({positions.length}) -

+
- - - + - - {positions.map((p, i) => { - const ccy = p.currency ?? 'USD' - const cost = Number(p.avgCost) * Number(p.quantity) - const pnl = Number(p.unrealizedPnL) - const pct = cost > 0 ? (pnl / cost) * 100 : 0 - const display = p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '?' + {groups.map((g) => { + const sumValue = g.positions.reduce((s, p) => s + Number(p.marketValue), 0) + const sumPnl = g.positions.reduce((s, p) => s + Number(p.unrealizedPnL), 0) + const currencies = new Set(g.positions.map(p => p.currency)) + const groupCcy = currencies.size === 1 ? [...currencies][0] : undefined + return ( - - - - - - - - - - - - + + + + + {g.positions.map((p, i) => ( + onCloseClick(p)} /> + ))} + ) })}
ContractCcy Side QtyAvg CostMarkAvg → Mark Mkt Value PnLPnL %
- {display} - {ccy} - - {p.side} - - {fmtNum(Number(p.quantity))}{fmt(Number(p.avgCost), ccy)}{fmt(Number(p.marketPrice), ccy)}{fmt(Number(p.marketValue), ccy)}= 0 ? 'text-green' : 'text-red'}`}> - {fmtPnl(pnl, ccy)} - = 0 ? 'text-green' : 'text-red'}`}> - {`${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%`} - - -
+
+
+ {assetClassLabel(g.class)} + · + {g.positions.length} position{g.positions.length > 1 ? 's' : ''} + {!groupCcy && ( + mixed ccy + )} +
+
+ {groupCcy ? fmt(sumValue, groupCcy) : `$${sumValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} + = 0 ? 'text-green' : 'text-red'}> + {groupCcy ? fmtPnl(sumPnl, groupCcy) : `${sumPnl >= 0 ? '+' : ''}${sumPnl.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} + +
+
+
-
+ + ) +} + +function PositionRow({ position: p, onClose }: { position: Position; onClose: () => void }) { + const ccy = p.currency ?? 'USD' + const cost = Number(p.avgCost) * Number(p.quantity) + const pnl = Number(p.unrealizedPnL) + const pct = cost > 0 ? (pnl / cost) * 100 : 0 + const display = p.contract.aliceId ?? p.contract.localSymbol ?? p.contract.symbol ?? '?' + + return ( + + + {display} + + + + {p.side} + + + {fmtNum(p.quantity)} + + {fmt(p.avgCost, ccy)} {fmt(p.marketPrice, ccy)} + + {fmt(p.marketValue, ccy)} + = 0 ? 'text-green' : 'text-red'}`}> +
{fmtPnl(pnl, ccy)}
+
{fmtPctSigned(pct)}
+ + + + + ) } @@ -342,127 +468,165 @@ interface OpenOrderRow { function OrdersSection({ orders }: { orders: unknown[] }) { const rows = orders as OpenOrderRow[] - return ( -
-

- Open Orders ({rows.length}) -

- {rows.length === 0 ? ( + if (rows.length === 0) { + return ( +
No open orders.
- ) : ( -
- - - - - - - - - - + + ) + } + return ( +
+
+
Order IDContractActionTypeQtyLimitStatus
+ + + + + + + + + + + + + {rows.map((o, i) => ( + + + + + + + + - - - {rows.map((o, i) => ( - - - - - - - - - - ))} - -
Order IDContractActionTypeQtyLimitStatus
{String(o.orderId ?? '—')} + {o.contract?.aliceId ?? o.contract?.localSymbol ?? o.contract?.symbol ?? '?'} + {o.order?.action ?? '—'}{o.order?.orderType ?? '—'}{String(o.order?.totalQuantity ?? '')}{o.order?.lmtPrice != null ? String(o.order.lmtPrice) : '—'} + {o.orderState?.status ?? 'Unknown'} +
{String(o.orderId ?? '—')} - {o.contract?.aliceId ?? o.contract?.localSymbol ?? o.contract?.symbol ?? '?'} - {o.order?.action ?? '—'}{o.order?.orderType ?? '—'}{String(o.order?.totalQuantity ?? '')}{o.order?.lmtPrice != null ? String(o.order.lmtPrice) : '—'} - {o.orderState?.status ?? 'Unknown'} -
-
- )} -
+ ))} + + +
+ ) } -// ==================== Snapshots ==================== +// ==================== Snapshots — vertical timeline ==================== -function SnapshotsSection({ snapshots, selected, onSelect }: { +interface SnapshotsTimelineProps { snapshots: UTASnapshotSummary[] - selected: UTASnapshotSummary | null - onSelect: (s: UTASnapshotSummary | null) => void -}) { - return ( -
-

- Recent Snapshots ({snapshots.length}) -

- - {selected && ( -
- onSelect(null)} /> -
- )} - - {snapshots.length === 0 ? ( -
- No snapshots yet. Snapshots are captured periodically (see Portfolio settings) or after each push. -
- ) : ( -
- {snapshots.map(s => ( - - ))} -
- )} -
- ) + expandedTimestamp: string | null + onToggle: (ts: string) => void } -// ==================== Formatting ==================== +function SnapshotsTimeline({ snapshots, expandedTimestamp, onToggle }: SnapshotsTimelineProps) { + // Group by calendar day. Snapshots are newest-first; preserve that order + // so the timeline reads top-down chronologically backwards (like git log). + const groups = useMemo(() => { + const map = new Map() + for (const s of snapshots) { + const day = new Date(s.timestamp).toDateString() + if (!map.has(day)) map.set(day, []) + map.get(day)!.push(s) + } + return Array.from(map.entries()) + }, [snapshots]) -const CURRENCY_SYMBOLS: Record = { - USD: '$', HKD: 'HK$', EUR: '€', GBP: '£', JPY: '¥', - CNY: '¥', CNH: '¥', CAD: 'C$', AUD: 'A$', CHF: 'CHF ', - SGD: 'S$', KRW: '₩', INR: '₹', TWD: 'NT$', BRL: 'R$', -} + if (snapshots.length === 0) { + return ( +
+
+ No snapshots yet. They are captured periodically (Portfolio → Snapshot Settings) or after each push. +
+
+ ) + } -function currencySymbol(currency?: string): string { - if (!currency) return '$' - return CURRENCY_SYMBOLS[currency.toUpperCase()] ?? `${currency} ` + return ( +
+
+ {/* Vertical guide line tucked behind the dots */} +
+ {groups.map(([day, items]) => ( +
+
+ {formatDayLabel(day)} +
+
    + {items.map((s) => { + const idxAll = snapshots.indexOf(s) + const prev = snapshots[idxAll + 1] // older snapshot + const delta = prev ? Number(s.account.netLiquidation) - Number(prev.account.netLiquidation) : null + const isExpanded = expandedTimestamp === s.timestamp + return ( +
  • + + {isExpanded && ( +
    + onToggle(s.timestamp)} + /> +
    + )} +
  • + ) + })} +
+
+ ))} +
+
+ ) } -function fmt(n: number, currency?: string): string { - const sym = currencySymbol(currency) - return n >= 1000 - ? `${sym}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - : `${sym}${n.toFixed(2)}` +function TriggerBadge({ trigger }: { trigger: string }) { + const label = trigger === 'post-push' ? 'push' + : trigger === 'post-reject' ? 'reject' + : trigger + return ( + + {label} + + ) } -function fmtPnl(n: number, currency?: string): string { - const sign = n >= 0 ? '+' : '' - return `${sign}${fmt(n, currency)}` +// ==================== Date helpers ==================== + +function formatDayLabel(dayString: string): string { + // dayString is the output of `Date.toDateString()` — locale-format it + // back into something more readable, with a "today" / "yesterday" hint. + const d = new Date(dayString) + const todayStr = new Date().toDateString() + const yesterdayStr = new Date(Date.now() - 24 * 60 * 60 * 1000).toDateString() + const formatted = d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) + if (dayString === todayStr) return `${formatted} · today` + if (dayString === yesterdayStr) return `${formatted} · yesterday` + return formatted } -function fmtNum(n: number): string { - return n >= 1 - ? n.toLocaleString('en-US', { maximumFractionDigits: 4 }) - : n.toPrecision(4) +function formatTime(timestamp: string): string { + const d = new Date(timestamp) + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) }