← All recipes
Build a Portfolio Tracker
≈ half a day
Show your users a live view of their holdings, P&L, and dividend history. Useful for wealth-management apps, neobanks with an investments tab, and B2B reporting dashboards.
The flow
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | GET /users/{id}/portfolio | Live holdings: units, avg cost, current USD price, unrealized P&L. |
| 2 | GET /market/quotes?symbols=A,B,C | Refresh prices for all held symbols in one batch request (max 50). |
| 3 | GET /stocks/{symbol}/chart | Pre-shaped price-history chart data per holding (1W, 1M, 3M, 1Y). |
| 4 | GET /users/{id}/dividends | Dividend income history with per-share amounts and pay dates. |
| 5 | GET /dividends/calendar | Upcoming declarations for stocks the user holds (partnerEligible). |
| 6 | GET /report/aum | Partner-level aggregate AUM across all sub-accounts (CSV export available). |
| 7 | GET /report/positions | Open positions by symbol across all sub-accounts. |
| 8 | Webhook: dividend.paid | Notify users the moment a dividend is credited to their wallet. |
Implementation
javascript
const BASE = process.env.MYSTOCKS_BASE_URL ?? 'https://mystocks.africa/api/v1/partner';
const API_KEY = process.env.MYSTOCKS_API_KEY;
if (!API_KEY) throw new Error('Set MYSTOCKS_API_KEY before running this recipe');
const IS_SANDBOX = BASE.includes('/sandbox/');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function logicalKey(...parts) {
return parts.map(part => String(part).trim().replace(/[^a-zA-Z0-9_-]/g, '-')).join('_');
}
async function api(path, { method = 'GET', body, idempotencyKey, headers = {}, maxAttempts = 4 } = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(BASE + path, {
method,
headers: {
'x-api-key': API_KEY,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const contentType = res.headers.get('content-type') || '';
const payload = contentType.includes('json') ? await res.json() : await res.text();
if (res.ok) return payload;
const envelope = payload && typeof payload === 'object' ? payload.error : null;
const code = envelope && typeof envelope === 'object'
? envelope.code
: payload && typeof payload === 'object' && payload.code || 'HTTP_' + res.status;
const message = envelope && typeof envelope === 'object'
? envelope.message
: typeof envelope === 'string' ? envelope
: payload && typeof payload === 'object' && payload.message || res.statusText;
const retryable = (res.status === 429 || res.status >= 500) && (method === 'GET' || idempotencyKey);
if (!retryable || attempt === maxAttempts - 1) {
throw Object.assign(new Error(code + ': ' + message), {
status: res.status, code, details: envelope?.details ?? payload?.details ?? null, payload,
retryAfterSeconds: Number(res.headers.get('Retry-After')) || null,
});
}
const retryAfter = Number(res.headers.get('Retry-After'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(1000 * (2 ** attempt), 30_000);
await sleep(delayMs + Math.floor(Math.random() * 250));
}
}
async function loadPortfolioScreen(subAccountId) {
// 1. Holdings + summary
const { holdings, summary } = await api('/users/' + subAccountId + '/portfolio');
// 2. Refresh prices for all held symbols in one call (chunk past 50)
const symbols = holdings.map(x => x.symbol);
const { data: quotes = [] } = symbols.length
? await api('/market/quotes?symbols=' + encodeURIComponent(symbols.join(',')))
: { data: [] };
const priceMap = Object.fromEntries(quotes.map(q => [q.symbol, q.usdPrice]));
// 3. Enrich holdings with the latest delayed observations
const enriched = holdings.map(holding => ({
...holding,
observedPrice: priceMap[holding.symbol] ?? holding.currentUsdPrice,
observedValue: (priceMap[holding.symbol] ?? holding.currentUsdPrice) * holding.units,
}));
// 4–5. Dividend history + upcoming
const { dividends } = await api('/users/' + subAccountId + '/dividends?limit=10');
const { declarations: upcoming } = await api('/dividends/calendar');
return { summary, holdings: enriched, recentDividends: dividends,
upcomingDividends: upcoming.filter(d => d.partnerEligible) };
}
// 6. Export AUM report to CSV
const csv = await api('/report/aum?format=csv');Common mistakes
- —Polling /market/quotes per holding instead of batching — one request covers 50 symbols.
- —Ignoring priceIsLive on portfolio rows: refresh stale prices with /market/quotes for the latest tick.
- —Rendering every calendar entry — filter on partnerEligible: true to skip stocks none of your users hold.
- —Rebuilding AUM by summing portfolios client-side when /report/aum (with CSV export) already aggregates it.
Prove it in sandbox first
- 1.Seed a sandbox sub-account with 2–3 instant-fill trades, then build the whole screen against sandbox data.
- 2.Verify your chunking logic by requesting 51+ symbols and handling the BATCH_LIMIT_EXCEEDED error.