Skip to main content

August 12, 2026

Timed-out eToro opens are at-most-once

A timeout on an eToro open can still fill the book. I treat open as at-most-once, leave closes on v1, and pick settlement from the matching leverage config.

Sander Korf5 min read

The live eToro execution workflow opens tickets on a real book and closes them later. An operator watching the account can get two fills for one intent, or a CFD name that rejects because settlement was stamped real. I moved opens to the documented v2 path and left closes on v1 so I could prove the open path before touching close. A timeout on open still looked retryable, which is how a book gets bought twice. settlementType still has to match the leverage config for that ticket.

I moved eToro opens to POST /api/v2/trading/execution/orders. Closes stayed on POST /api/v1/trading/execution/market-close-orders/positions/{positionId}.

v2 open is the path eToro now documents: action: open, transaction of buy or sellShort, orderType: mkt, plus instrumentId, leverage, and amount. settlementType is optional. Closes still send InstrumentId and UnitsToDeduct on the v1 route I already understood.

I staged the open. Changing close in the same cut would have meant a new request shape on both sides, and I already did not trust timeouts on open. The close waits until the new open has lived in production.

// open (v2), at-most-once: do not retry timeouts/5xx
POST /api/v2/trading/execution/orders
{ action: 'open', transaction: 'buy' | 'sellShort', instrumentId, orderType: 'mkt', leverage, amount }
 
// close (v1, unchanged)
POST /api/v1/trading/execution/market-close-orders/positions/{positionId}
{ InstrumentId, UnitsToDeduct }

Timeouts on eToro open stay ambiguous

A timeout or a 5xx on an eToro open does not mean try again. eToro may have filled and dropped the response. I get no id. Send the same open again and I maybe get a second fill.

Missing positions are recoverable. I reconcile later and place a fresh order on purpose. Duplicates are messy. I treat eToro open as at-most-once: a 2xx with an id is done. A 4xx, except a rate-limit, is a hard fail. Timeout or 5xx is ambiguous, so I mark it and I do not send that open again.

Close is a different animal. I already have a position id. A retry there does not invent a second position the way a retried open can. I am still not changing that eToro path in this cut.

Paper mode short-circuits both

Paper mode never hits the live eToro routes. Both open and close return a receipt that looks like paper-${uuid}. Same client code. Fake fill at the edge. I can exercise the mixed-version eToro client without paying for a real fill, and without teaching tests to retry a timeout they should never retry.

Pick settlementType from the matching config

settlementType on that v2 open is optional. Treating real as the honest default is still a trap.

I used to send settlementType: real whenever eToro eligibility listed a real row. Real stock. Real custody. An eToro real config often exists only at 1x. The same instrument at 5x is a different product. Eligibility lists that ticket as CFD. If I send settlementType: real on an order whose matching config is CFD at that leverage, I get a silent reject, or I open something I did not model: same symbol on a CFD ticket with a real stamp.

Crypto and commodities made the rule obvious. Those eToro books are typically CFD only. There is no real row to fall back on. Equities hid the same constraint behind a 1x real config that looks like a default and is not.

I do not pick settlementType from the first real-looking eToro row. I filter leverageConfigs by side and leverage, then choose CFD vs real from that slice.

export function preferredSettlementType(
	eligibility: Eligibility,
	side: Side,
	leverage: number
): 'cfd' | 'real' | undefined {
	const matching = eligibility.leverageConfigs.filter(
		(c) => c.direction === side && (c.leverageValues.length === 0 || c.leverageValues.includes(leverage))
	);
	return (
		matching.find((c) => c.settlementType === 'real' && !c.isPotential)?.settlementType ??
		matching.find((c) => c.settlementType === 'cfd' && !c.isPotential)?.settlementType
	);
}

Non-potential real wins when it matches this eToro ticket. Else CFD. Else I omit settlementType. Potential rows stay out. They are hints, not live eligibility.

Buy and sell are not interchangeable. A long real row does not authorize a short CFD at the same leverage. If leverageValues is empty, that config accepts any leverage on that side. If it lists numbers, my ticket has to be one of them.

minPositionAmount has the same trap. An eToro real row at 1x can carry a different minimum than the CFD row at 5x. Pull the minimum from a random real config and pair it with a CFD settlementType at that leverage, and I will either undersize the ticket or invent a product that does not exist. Same matching config. Same side. Same leverage. CFD vs real and the minimum come out together.

I shipped the documented eToro v2 open and left the working v1 close. I do not retry a timeout on open. Eligibility has to say what this ticket actually is before I stamp settlementType.


Happy coding! Sander