← 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
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | Your scheduler | Fire once per user per period (e.g. monthly). The period, not the attempt, defines the operation. |
| 2 | GET /market/status | Check the keyed exchange entry for the target instrument. Closed? Use a production resting order or defer to nextOpen. |
| 3 | GET /quote/{symbol} | Firm quote for the period amount (cashValue). Returns quoteId + full fee breakdown. |
| 4 | POST /users/{id}/trade | Market BUY with quoteId and Idempotency-Key dca_{userId}_{YYYY-MM} — reruns replay, never double-buy. |
| 5 | Production alternative: LIMIT order | orderType LIMIT + timeInForce GTD rests as WORKING until price + hours align. Sandbox does not simulate this path. |
| 6 | Webhook: order.filled | Confirm the period as invested only when settlement lands. INSUFFICIENT_FUNDS → notify, skip, or top up. |
| 7 | GET /users/{id}/tax-lots | Each 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.