Skip to content
Partner API Docs
← All recipes

Recurring Investing (DCA)

≈ 1 day

Dollar-cost averaging is a cron job with money-movement discipline: a deterministic Idempotency-Key per user per period makes the whole run safely re-runnable, and resting LIMIT orders cover schedules that fire while an exchange is closed.

The flow

StepEndpoint / EventWhat it does
1Your schedulerFire once per user per period (e.g. monthly). The period, not the attempt, defines the operation.
2GET /market/statusCheck the keyed exchange entry for the target instrument. Closed? Use a production resting order or defer to nextOpen.
3GET /quote/{symbol}Firm quote for the period amount (cashValue). Returns quoteId + full fee breakdown.
4POST /users/{id}/tradeMarket BUY with quoteId and Idempotency-Key dca_{userId}_{YYYY-MM} — reruns replay, never double-buy.
5Production alternative: LIMIT orderorderType LIMIT + timeInForce GTD rests as WORKING until price + hours align. Sandbox does not simulate this path.
6Webhook: order.filledConfirm the period as invested only when settlement lands. INSUFFICIENT_FUNDS → notify, skip, or top up.
7GET /users/{id}/tax-lotsEach DCA buy becomes its own FIFO tax lot — feed the year-end CGT statement for free.

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));
  }
}
const ACTIVE_ORDER_STATES = new Set(['PENDING', 'WORKING', 'PROCESSING']);
const TERMINAL_ORDER_STATES = new Set(['FILLED', 'REJECTED', 'CANCELLED']);

function handleOrderStatus(order) {
  if (ACTIVE_ORDER_STATES.has(order.status)) return { settled: false, status: order.status };
  if (order.status === 'FILLED') return { settled: true, status: 'FILLED' };
  if (order.status === 'REJECTED') return { settled: true, status: 'REJECTED', reason: order.rejectionReason };
  if (order.status === 'CANCELLED') return { settled: true, status: 'CANCELLED' };
  throw new Error('Unknown order status: ' + order.status);
}

async function runDcaPeriod(subAccountId, userId, symbol, usdAmount, period /* '2026-07' */) {
  // Deterministic key: SAME key for every retry of this user+period.
  const idem = logicalKey('dca', userId, period);

  // Resolve the instrument's exchange, then look it up in the keyed status object.
  const stock = await api('/stocks/' + encodeURIComponent(symbol));
  const { exchanges } = await api('/market/status');
  const exch = exchanges[String(stock.exchange).toUpperCase()];
  if (!exch) throw new Error('No market status returned for exchange ' + stock.exchange);

  if (exch.status !== 'OPEN') {
    // Sandbox requires quote-based market orders and does not simulate resting LIMIT orders.
    if (IS_SANDBOX) return { status: 'DEFERRED', nextOpen: exch.nextOpen };

    // 5. Production-only GTD LIMIT: rests as WORKING until price + hours align.
    const order = await api('/users/' + subAccountId + '/trade', {
      method: 'POST', idempotencyKey: idem,
      body: {
        symbol, type: 'BUY', cashValue: usdAmount,
        orderType: 'LIMIT', limitPrice: stock.price * 1.02,   // local currency, small buffer
        timeInForce: 'GTD', expiresAt: endOfPeriodIso(period),
      },
    });
    return { order, state: handleOrderStatus(order) }; // Production returns WORKING while resting.
  }

  // 3–4. Standard two-step market buy
  const quotePath = '/quote/' + encodeURIComponent(symbol) + '?type=BUY&cashValue=' +
    encodeURIComponent(usdAmount) + '&subAccountId=' + encodeURIComponent(subAccountId);
  const quote = await api(quotePath);

  try {
    const order = await api('/users/' + subAccountId + '/trade', {
      method: 'POST', idempotencyKey: idem,
      body: { symbol, type: 'BUY', cashValue: usdAmount, quoteId: quote.quoteId },
    });
    return { order, state: handleOrderStatus(order) }; // PENDING in production, FILLED in sandbox.
  } catch (err) {
    if (err.code === 'INSUFFICIENT_FUNDS') return notifyAndSkip(userId, period);
    throw err;
  }
}

Common mistakes

  • Random idempotency keys per attempt. The key must encode user + period (dca_user42_2026-07) so a crashed run can be re-executed wholesale without double-buying.
  • Failing the whole run when an exchange is closed. Either defer to market/status nextOpen or place a GTD LIMIT resting order that survives until it fills or expires.
  • Marking the period invested at 202. The order is only PENDING — confirm on order.filled; a rejection releases the escrow and the period should retry or notify.
  • Quoting once and reusing the quoteId across users. Quotes are single-use and per sub-account — one quote per trade.
  • Ignoring INSUFFICIENT_FUNDS. Decide the product behavior up front: skip the period, partial buy, or auto-deposit from the master wallet first.

Prove it in sandbox first

  • 1.Run the quote-based market-order path against the sandbox base URL — fills are instant, so one open-market run gives you the completed period and tax lot.
  • 2.Sandbox does not simulate resting LIMIT orders. When the exchange is closed, this example returns DEFERRED with nextOpen; certify GTD LIMIT placement during the controlled production pilot.
  • 3.Re-run the identical job twice and verify the second run replays cached responses (same Idempotency-Keys) with no duplicate orders.