demo D299
A portfolio risk explorer
50,000 positions shaped like notebook output: three portfolio figures, exposure by sector, a derived sector summary and the positions, with an edit printed back as the Python cell output it would be
createStat · mode: derived · createChart
Fifty thousand holdings laid out the way a notebook prints them: portfolio value, unrealised P&L and value at risk, exposure by sector, a sector summary derived from the positions themselves, and the positions. Pick a sector and every figure recomputes over what is left; edit a quantity or a price and the panel underneath prints what comes back in Python.
The configuration
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.min.js"></script>
<div id="tiles" style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px"></div>
<div id="exposure" style="height: 300px"></div>
<div id="sectors" style="height: 300px"></div>
<div id="positions" style="height: 400px"></div>
<script type="module">
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
const USD = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 };
const COMPACT = { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 };
// The positions. Quantity and Price are editable; everything else is read
// only, so an edit is always one of the two values the round trip is about.
const positions = LatticeGrid.createGrid(document.getElementById('positions'), {
rowKey: 'id',
edit: true,
selection: { mode: 'single' },
columnDefaults: { filter: true },
state: { sort: [{ col: 'marketValue', dir: 'desc' }] },
columns: [
{ field: 'ticker', title: 'Ticker', edit: false },
{ field: 'instrument', title: 'Instrument', edit: false },
{ field: 'sector', title: 'Sector', filter: { type: 'set' }, edit: false },
{ field: 'quantity', title: 'Quantity', type: 'number', edit: true },
{ field: 'price', title: 'Price', type: 'number', format: { style: 'currency', currency: 'USD' }, edit: true },
{ field: 'marketValue', title: 'Market value', type: 'number', format: USD, edit: false },
{ field: 'pnl', title: 'P&L', type: 'number', format: USD, edit: false },
{ field: 'volatility', title: 'Volatility', type: 'number', format: { decimals: 2, suffix: '%' }, edit: false },
{ field: 'var95', title: 'VaR 95%', type: 'number', format: USD, edit: false },
],
rows,
});
const total = (col) => positions.statistics.reduce(col, 'sum');
// Value at risk is banded on the share of exposure it represents rather than
// on the money, so the tile reads the same whether the whole book or one
// sector is in view. Less at risk is better, hence goodWhen: 'down'.
LatticeGrid.createStat({
grid: positions,
container: document.querySelector('#tiles'),
title: 'VaR 95%',
value: () => total('var95'),
goodWhen: 'down',
bands: (value) => {
const exposure = total('marketValue');
if (typeof value !== 'number' || !exposure) return null;
const share = value / exposure;
if (share <= 0.02) return 'good';
if (share <= 0.035) return 'warn';
return 'bad';
},
footer: 'one day, 95% confidence',
});
// The sector summary reads whatever the positions grid is filtered to, so a
// filter above rewrites it with no second query.
const bySector = LatticeGrid.createGrid(document.getElementById('sectors'), {
source: {
mode: 'derived', from: positions, follow: 'filtered', refresh: 'live',
groupBy: 'sector',
select: {
exposure: { of: 'marketValue', fn: 'sum' },
ret: { of: 'dailyReturn', fn: 'avg' },
vol: { of: 'volatility', fn: 'avg' },
var95: { of: 'var95', fn: 'sum' },
},
},
state: { sort: [{ col: 'var95', dir: 'desc' }] },
columns: [
{ field: 'sector', title: 'Sector' },
{ field: 'exposure', title: 'Exposure', type: 'number', format: COMPACT },
{ field: 'ret', title: 'Return', type: 'number', format: { decimals: 2, suffix: '%' } },
{ field: 'vol', title: 'Volatility', type: 'number', format: { decimals: 2, suffix: '%' } },
{ field: 'var95', title: 'VaR 95%', type: 'number', format: COMPACT },
],
});
// The chart draws the summary, so it follows the same filter one more step
// down the chain.
createChart({
grid: bySector,
container: '#exposure',
type: 'horizontalBar',
x: 'sector',
y: 'exposure',
});
// Clicking a sector row narrows the positions, and everything above follows.
bySector.on('row:clicked', (e) => {
positions.filters.set({ col: 'sector', op: 'eq', value: e.row.data.sector });
});
</script>
A book of fifty thousand positions, and the sector that is wrong
The four figures at the top are the ones a risk report opens with: what the book is worth, what it is up or down against what was paid for it, how much of it is at risk over one day at 95% confidence, and how many holdings those three numbers were read from. Across the whole book they look ordinary. The book is modestly ahead and the value at risk sits at a share of exposure nobody would question.
Everything on this screen reads the positions grid at the bottom. The tiles are bound to it, the sector summary is derived from it, and the chart draws the summary, so narrowing the positions narrows all of them together and nothing on the screen can disagree with anything else on it.
The sector summary is sorted most at risk first, and the top two rows are the whole story. Technology and Energy carry almost exactly the same value at risk. Technology holds about a quarter of the book. Energy holds about a twentieth of it. Energy’s holdings swing roughly five times as widely as everything else, and it is the one sector that is underwater. Click it and the screen becomes that sector alone: the value tile falls to what Energy is worth, the P&L tile turns negative, and value at risk goes from a comfortable share of the exposure to a bad one. That is the finding a single blended number was hiding.
Editing a value, and getting it back in Python
Quantity and Price are editable. Change either one and the panel at the bottom prints the cell output the same edit produces in a notebook: the row you touched, and the type the column still holds afterwards. Quantity is a whole number and comes back a whole number. Price is a decimal and comes back a decimal. That is the round trip the Python packages are for, and the type line underneath is what makes it checkable rather than merely claimed.
No Python runs on this page, and the panel says so. What runs here is the grid, which is the part the notebook widget puts in your cell.
What each figure is
Portfolio value and unrealised P&L are sums over the positions in view. The P&L tile is banded on return against cost basis rather than on the money itself, so it means the same thing whether the whole book or one sector is in view. Value at risk is the sum of each position’s own one-day 95% figure and is banded on the share of exposure it represents, for the same reason. A sum of position-level figures is the conservative reading: it credits the book with no offsetting between holdings.
The sector summary is a derived grid. It groups whatever the positions grid is currently filtered to and reduces it, so a filter above rewrites the panel with no second query and no second copy of the data. Return and volatility are averages across the holdings in each sector; exposure and value at risk are sums.
How do I put a pandas DataFrame behind a screen like this?
Hand the frame to the notebook widget and it becomes the positions grid, typed from your dtypes, with edits flowing back to grid.df. The tiles, the chart and the sector summary in this demo are the browser-side grid at work: createStat tiles bound to the positions grid, a source: { mode: 'derived', from: positions, follow: 'filtered', groupBy: 'sector', select: { … } } grid over it, and a chart drawing that summary. In a notebook you would compute those figures in pandas beside the widget instead, and put the grouped frame into a second widget of its own.