Skip to content
Partner API Docs
← 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

StepEndpoint / EventWhat it does
1GET /users/{id}/portfolioLive holdings: units, avg cost, current USD price, unrealized P&L.
2GET /market/quotes?symbols=A,B,CRefresh prices for all held symbols in one batch request (max 50).
3GET /stocks/{symbol}/chartPre-shaped price-history chart data per holding (1W, 1M, 3M, 1Y).
4GET /users/{id}/dividendsDividend income history with per-share amounts and pay dates.
5GET /dividends/calendarUpcoming declarations for stocks the user holds (partnerEligible).
6GET /report/aumPartner-level aggregate AUM across all sub-accounts (CSV export available).
7GET /report/positionsOpen positions by symbol across all sub-accounts.
8Webhook: dividend.paidNotify 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.