Skip to content
← 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 whether the target exchange is open. Closed? Use a resting order (step 5) 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.
5Alternative: LIMIT orderorderType LIMIT + timeInForce GTD is placeable while the market is closed; it rests as WORKING until price + hours align.
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 = 'https://mystocks.africa/api/v1/partner';
const h = { 'x-api-key': process.env.MYSTOCKS_API_KEY, 'Content-Type': 'application/json' };

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

  // 2. Market open? If not, place a resting LIMIT instead of failing the run.
  const { exchanges } = await fetch(`${BASE}/market/status`, { headers: h }).then(r => r.json());
  const exch = exchanges.find(e => symbol.endsWith('.' + e.suffix) || e.code === symbol.split('.').pop());

  if (exch && exch.status !== 'OPEN') {
    // 5. Resting GTD LIMIT at ~market: rests as WORKING, fills when price + hours align.
    const stock = await fetch(`${BASE}/stocks/${symbol}`, { headers: h }).then(r => r.json());
    return fetch(`${BASE}/users/${subAccountId}/trade`, {
      method: 'POST', headers: { ...h, 'Idempotency-Key': idem },
      body: JSON.stringify({
        symbol, type: 'BUY', cashValue: usdAmount,
        orderType: 'LIMIT', limitPrice: stock.price * 1.02,   // local currency, small buffer
        timeInForce: 'GTD', expiresAt: endOfPeriodIso(period),
      }),
    }).then(r => r.json());
  }

  // 3–4. Standard two-step market buy
  const quote = await fetch(
    `${BASE}/quote/${symbol}?type=BUY&cashValue=${usdAmount}&subAccountId=${subAccountId}`,
    { headers: h }
  ).then(r => r.json());

  const res = await fetch(`${BASE}/users/${subAccountId}/trade`, {
    method: 'POST', headers: { ...h, 'Idempotency-Key': idem },
    body: JSON.stringify({ symbol, type: 'BUY', cashValue: usdAmount, quoteId: quote.quoteId }),
  });

  if (res.status === 400) {
    const err = await res.json();
    if (err.error?.code === 'INSUFFICIENT_FUNDS') return notifyAndSkip(userId, period);
  }
  return res.json(); // 202 — confirm the period on the order.filled webhook, not here
}

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 whole job against the sandbox base URL — fills are instant, so one run gives you a full period lifecycle including tax lots.
  • 2.Test the resting path by placing a GTD LIMIT with a far-away limitPrice, then cancel it and confirm the escrow releases.
  • 3.Re-run the identical job twice and verify the second run replays cached responses (same Idempotency-Keys) with no duplicate orders.