Portfolio Terminal — 2,000 positions, no mouse required
Self-initiated concept. Not a client project, and no real market data appears in it. Design and build: Canon Chong · ~1 day · React 19, Next.js 16, hand-rolled SVG, custom design system
The problem
Portfolio tools tend to fail in one of two directions.
The pretty ones show twenty holdings beautifully and fall apart at two thousand — the layout was designed for a screenshot, not a book. The capable ones show two thousand and are unreadable: every number the same weight, every column the same colour, and the only way to find anything is to already know where it is.
The second failure has a subtler cousin. Most terminals advertise "linked panels" and deliver synchronised filters — the chart and the table apply the same WHERE clause to numbers that were computed once. Brush a six-week window and the table still shows you annual returns. The panels agree with each other and disagree with the question you just asked.
Constraints I set
- No backend. Everything is generated from a seed. That is not a shortcut — it forced the data to
be defensible, which turned out to be most of the work.
- Full keyboard operation. Not "everything is tab-reachable". Spreadsheet-grade: cell cursor,
range extension, page jumps, type-ahead. Traders do not use the mouse.
- The system is the only vocabulary. No hard-coded colours anywhere, including in the charts.
- It has to survive someone who does this for a living. They check the numbers before the layout.
Decisions
The data had to be defensible before anything was drawn
A dashboard is only as credible as the figures in it, so the generator was validated before a single panel existed. Three findings from reading the output rather than the code:
The book came out 69% FX and 0.6% bonds. Sizing each position as a plausible lot times a plausible price gives individually sensible lots and a portfolio that is nonsense. Sizes are now solved so each asset class hits its target weight, then quantity is derived from value and the value recomputed from the rounded quantity — so price × quantity actually equals the market value shown beside it. That is the first thing anyone in this industry checks.
Sector correlations came out at a median of 0.49 and a maximum of 0.98. The file header described a factor model with sector factors. The model never had any — every equity loaded on Market alone, so "sector correlation" was one factor echoing itself. Sectors and regions are now real return streams. Median 0.29, range −0.02 to 0.87.
Equity and Fund aggregates correlated at 0.98 — the same bet under two labels. Fund market beta now spans negative, the way a book holding market-neutral and long/short mandates does. Now 0.77.
The seed was chosen by generating six years and picking a plausible one rather than a flattering one: +7.01% return, 7.53% volatility, −5.34% max drawdown, Sharpe 0.89, 60% of positions profitable.
Correlation falls out of structure, not out of a table of numbers
Every position loads onto 23 factors — eight macro and style, ten sector, five region — plus an idiosyncratic term. Two European banks co-move because they share two factors, not because a number was written down saying they do.
This is what makes the risk panel a risk tool rather than a table with colours in it, and it is why the heatmap tells you something new when you brush a different period.
Brushing rewrites the window, it does not filter the output
The chart's brush changes the period every calculation runs over. Selecting 79 days out of the year moves the return from +7.01% to +4.42%, Sharpe from 0.89 to 1.86, max drawdown from −5.34% to −3.09%, the table's period column for every row, and the most correlated sector pair from Industrials × Consumer to Industrials × Telecom.
That last one is the whole argument for the panel. Correlations are not constants. A book that looks diversified across a year can be one position over the six weeks that actually hurt, and a tool that computes correlation once cannot tell you that.
The window commits on pointer release rather than during the drag — recomputing 2,000 × 260 returns per pointermove drops frames. The brush rectangle is cheap; the recompute is not.
Charts are hand-rolled SVG, and not for bundle size
Charting libraries assume they own colour. Getting one to resolve --data-positive at runtime and follow a live theme switch means fighting its theming layer the entire way.
Hand-rolled, the stroke is a CSS variable. The theme toggle works on the chart for the same reason it works everywhere else: nothing was hard-coded. Zero hex values in the component layer, verified.
Virtualisation, keyboard and density all depend on each other
Three requirements that fight:
- virtualisation keeps most of the 2,000 rows out of the DOM
- keyboard navigation needs focus to land on cells that may not exist yet
- density switching changes the row height the other two are calculated from, at runtime
The order that makes them coexist: move the scroll position imperatively so the target row will render, mirror that scroll into state so the slice updates in the same commit, then focus in a layout effect. Focusing before scrolling focuses nothing and drops the keypress silently.
Row height is measured from a probe element carrying --row-h, watched with a ResizeObserver. Compact then propagates into the virtualisation maths for free — no listener on the attribute, no hard-coded table of pixel values. Verified: 44px with an 88,000px spacer becomes 32px with 64,000px, and jumping to the last row afterwards lands scroll position exactly on its maximum.
Bugs that only appear when you drive it
Four, and the interesting thing is that none were visible in the source.
Held-down arrow keys dropped presses. Key repeat fires faster than React re-renders, so several handlers ran against one committed state and every repeat computed from the same starting cell — five presses moved one row. The cursor now lives in a ref written synchronously. Space-toggling rows had the identical bug: five presses selected one row.
The grid pushed both side panels off the viewport. A flex item defaults to min-width: auto, so the grid refused to shrink below the 1,800px its own content wanted. Measured at x=1780 in a 1,180px window. The grid itself looked perfect, which is exactly what made it invisible.
Dragging the chart did nothing in the default state. With no brush yet the window spans everything, so "the pointer is inside the current selection" was true everywhere, every drag read as "move the existing brush", and a brush covering the full width cannot move. The one state every first-time viewer starts in was the one where the headline interaction was dead.
`setPointerCapture` threw and aborted the handler before the drag was registered. Capture is an optimisation, not load-bearing.
Result
A terminal where 2,000 positions across six asset classes stay legible, the keyboard does everything the mouse does, and brushing a period genuinely re-answers the question in all three panels.
What it demonstrates: that I can hold a dense financial interface together — and that I check the numbers, drive the thing, and fix what that turns up, rather than shipping the version that looks right in a screenshot.
Concept work, built to explore the problem. Generated data throughout; no affiliation with any firm or venue.