≈ 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 whether the target exchange is open. Closed? Use a resting order (step 5) 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 | Alternative: LIMIT order | orderType LIMIT + timeInForce GTD is placeable while the market is closed; it rests as WORKING until price + hours align. |
| 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
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
Prove it in sandbox first