Building a portfolio copilot
A "portfolio copilot" is the kind of assistant a retail brokerage or an asset-manager dashboard ships: paste your holdings, and the copilot can answer "how did my portfolio do today", "what's driving the move on NVDA", "what news is hitting my biggest position", "show me the 30-day chart for everything I hold."
The data plumbing for that is a fan-out problem. For a 25-ticker portfolio you need:
- 25 current quotes, with change and change-percent
- 25 price-history series for charts
- 25 news streams, newest-first, capped at maybe 5 articles each
- (optional) 25 fundamentals snapshots for context
- (optional) 25 technicals signals for "is anything in my portfolio overbought"
That's 100+ upstream API calls if you build it naively against a per-ticker REST API. With Jintel it's a single GraphQL request — the batched top-level queries (quotes, priceHistory) and the batched entity query (entitiesByTickers) all accept ticker arrays and fan out internally with deduplication and rate-limit isolation.
The shape of the data the copilot needs
There are two query shapes worth combining:
- Snapshot —
quotes(tickers)for current price + change. Cheapest possible call. Use this every time the dashboard refreshes. - Detail —
entitiesByTickers(tickers)with nested sub-graphs for everything else (news, fundamentals, technicals). Use this when the user opens a portfolio summary or asks a specific question.
Crypto holdings are transparent — quotes(tickers: ["AAPL", "BTC", "ETH", "TSLA"]) Just Works. Equity tickers route through Yahoo Finance, crypto symbols route through CoinGecko / Binance, and the result comes back in the same array shape.
A portfolio refresh in one round trip
query Portfolio($tickers: [String!]!, $range: String!) {
quotes(tickers: $tickers) {
ticker price changePercent volume marketCap
}
priceHistory(tickers: $tickers, range: $range) {
ticker
history(filter: { sort: ASC }) {
date close volume
}
}
entitiesByTickers(tickers: $tickers) {
name
tickers
market {
fundamentals { sector industry peRatio }
}
technicals { rsi ema50 sma200 }
news(filter: { limit: 3, sort: DESC }) {
title date sentimentScore link source
}
}
}
Three top-level query selections, one HTTP call, one quote / one credit deduction (or one x402 settlement if you're on the pay-per-query rail). The Mercurius layer batches the underlying upstream calls and deduplicates anything overlapping.
TypeScript copilot pattern
import { JintelClient } from "@yojinhq/jintel-client";
const jintel = new JintelClient({ apiKey: process.env.JINTEL_API_KEY });
interface Holding { ticker: string; shares: number; costBasis: number; }
const PORTFOLIO_QUERY = `
query($t: [String!]!, $range: String!) {
quotes(tickers: $t) {
ticker price changePercent volume marketCap
}
priceHistory(tickers: $t, range: $range) {
ticker
history(filter: { sort: ASC }) { date close }
}
entitiesByTickers(tickers: $t) {
name tickers
market { fundamentals { sector } }
technicals { rsi }
news(filter: { limit: 3, sort: DESC }) {
title date sentimentScore link
}
}
}
`;
export async function refreshPortfolio(holdings: Holding[]) {
const tickers = holdings.map((h) => h.ticker);
const { data } = await jintel.request(PORTFOLIO_QUERY, {
t: tickers,
range: "1mo",
});
// Index everything by ticker for the UI
const byTicker = new Map<string, {
quote: typeof data.quotes[number];
history: { date: string; close: number }[];
entity: typeof data.entitiesByTickers[number];
}>();
for (const q of data.quotes) {
byTicker.set(q.ticker, { quote: q, history: [], entity: null as any });
}
for (const ph of data.priceHistory) {
const slot = byTicker.get(ph.ticker);
if (slot) slot.history = ph.history;
}
for (const e of data.entitiesByTickers) {
const t = e.tickers?.[0];
if (t && byTicker.has(t)) byTicker.get(t)!.entity = e;
}
// Compute portfolio-level metrics
const totalValue = holdings.reduce((acc, h) => {
const px = byTicker.get(h.ticker)?.quote?.price ?? 0;
return acc + px * h.shares;
}, 0);
const totalCost = holdings.reduce((acc, h) => acc + h.costBasis * h.shares, 0);
const pnl = totalValue - totalCost;
return { byTicker, totalValue, totalCost, pnl };
}
The copilot's natural-language layer (Claude, GPT, an MCP server) gets a fully-indexed byTicker map plus aggregate metrics, which is enough to answer most user questions without any extra calls. For deeper questions ("why is NVDA down today?") the agent can issue follow-up GraphQL queries for that specific ticker — earnings, insider trades, analyst consensus — using the same entitiesByTickers shape.
Drop it straight into Claude Desktop
If you don't want to write the natural-language layer yourself, the Jintel MCP server exposes every query above as a callable tool. Add Jintel to a Claude Desktop or Cursor configuration and the model can issue these queries directly when the user asks about their portfolio.
- curl
curl https://api.jintel.ai/api/graphql \
-H "Authorization: Bearer $JINTEL_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{
"query": "query($t:[String!]!,$r:String!){quotes(tickers:$t){ticker price changePercent} priceHistory(tickers:$t,range:$r){ticker history(filter:{sort:ASC}){date close}} entitiesByTickers(tickers:$t){tickers news(filter:{limit:3}){title date sentimentScore}}}",
"variables": {
"t": ["AAPL", "MSFT", "NVDA", "AMZN", "BTC"],
"r": "1mo"
}
}
JSON
Where to go next
- Batching — how loaders deduplicate fan-out across a request.
- Filtering — the date / sort / limit inputs for array sub-graphs.
- TypeScript SDK — typed bindings for every query above.
- MCP server — turn this into a Claude Desktop tool.
- Agents & x402 — pay-per-query if the copilot is running with its own wallet.