Table of contents
Fraud in a retail forex trading platform looks different from payment fraud. Nobody is stealing credit card numbers. Instead, you're watching for traders who exploit system behavior—bonus abuse, wash trading, account sharing, and arbitrage of pricing latency. Finding these behaviors in a stream of MT5 trading data requires pattern recognition at multiple timescales simultaneously.
Here's the detection system we built at Islero Capital.
What We Were Actually Watching For
Our fraud categories fell into four buckets:
Bonus abuse — Opening an account, claiming the welcome bonus, placing offsetting positions that cancel out (locking the bonus with no real risk), then withdrawing.
Wash trading — Repeatedly opening and closing the same position pair to inflate trading volume metrics without real market exposure.
Account sharing — Multiple users logging into the same MT5 account from different IPs, devices, or geographies simultaneously.
Latency arbitrage — Exploiting the brief window between our price feed and market price during high-volatility events to open risk-free positions.
Each requires different detection logic and a different response.
Detection Architecture
We run detection as a stream processor that consumes trade events from the same Kafka topic used by the real-time pipeline:
type FraudSignal = {
account_id: string;
signal_type: FraudSignalType;
severity: "low" | "medium" | "high";
evidence: Record<string, unknown>;
detected_at: Date;
};
type FraudSignalType =
| "bonus_abuse_suspected"
| "wash_trade_pattern"
| "multi_login_detected"
| "latency_arb_pattern"
| "unusual_lot_size"
| "rapid_open_close";
class FraudDetector {
private readonly rules: FraudRule[];
async evaluate(event: TradeEvent): Promise<FraudSignal[]> {
const signals: FraudSignal[] = [];
for (const rule of this.rules) {
const signal = await rule.check(event);
if (signal) signals.push(signal);
}
return signals;
}
}Each rule is an independent module. This lets us add, tune, or disable rules without touching the core detector.
Specific Detection Rules
Wash Trade Detection
Wash trading leaves a distinctive pattern: positions on the same symbol in opposite directions, opened within a short window, with similar lot sizes.
class WashTradeRule implements FraudRule {
async check(event: TradeEvent): Promise<FraudSignal | null> {
if (event.type !== "position_opened") return null;
// Look for recently opened opposing position on the same symbol
const opposing = await db.query(
`SELECT * FROM positions
WHERE account_id = $1
AND symbol = $2
AND direction = $3
AND status = 'open'
AND open_time > NOW() - INTERVAL '5 minutes'
AND ABS(lots - $4) / $4 < 0.1`, // within 10% lot size
[
event.account_id,
event.symbol,
event.direction === "buy" ? "sell" : "buy",
event.lots,
]
);
if (opposing.rows.length === 0) return null;
return {
account_id: event.account_id,
signal_type: "wash_trade_pattern",
severity: "medium",
evidence: {
new_position: event,
opposing_position: opposing.rows[0],
},
detected_at: new Date(),
};
}
}Multi-Login Detection
We track login events (session start, IP, device fingerprint) and flag when an account has concurrent sessions from different geographies:
class MultiLoginRule implements FraudRule {
async check(event: TradeEvent): Promise<FraudSignal | null> {
if (event.type !== "session_started") return null;
const recentSessions = await redis.smembers(
`sessions:${event.account_id}:active`
);
for (const sessionJson of recentSessions) {
const session = JSON.parse(sessionJson);
const distance = getGeoDistance(event.ip_country, session.ip_country);
// Same account, different country, same time window
if (distance > 1000 && isWithinMinutes(session.started_at, event.ts, 30)) {
return {
account_id: event.account_id,
signal_type: "multi_login_detected",
severity: "high",
evidence: {
session_1: session,
session_2: { ip: event.ip, country: event.ip_country, ts: event.ts },
distance_km: distance,
},
detected_at: new Date(),
};
}
}
return null;
}
}Bonus Abuse Pattern
Bonus abuse is harder to detect in real time because the pattern unfolds over days. We run this as a nightly batch check rather than a stream check:
async function detectBonusAbuse(accountId: string) {
const account = await getAccount(accountId);
if (!account.bonus_claimed_at) return null;
const postBonusTrades = await db.query(
`SELECT * FROM positions
WHERE account_id = $1
AND open_time > $2
ORDER BY open_time ASC`,
[accountId, account.bonus_claimed_at]
);
// Check for offsetting position pairs
let offsettingPairs = 0;
for (let i = 0; i < postBonusTrades.rows.length - 1; i++) {
const a = postBonusTrades.rows[i];
const b = postBonusTrades.rows[i + 1];
if (
a.symbol === b.symbol &&
a.direction !== b.direction &&
Math.abs(a.lots - b.lots) < 0.01 &&
Math.abs(a.open_time - b.open_time) < 60_000 // within 60 seconds
) {
offsettingPairs++;
}
}
const offsetRatio = offsettingPairs / (postBonusTrades.rows.length / 2);
if (offsetRatio > 0.7) {
return createSignal("bonus_abuse_suspected", "high", { offsetRatio, accountId });
}
}RBAC Response
Fraud signals trigger graduated responses via the RBAC system. Severity determines what changes:
| Severity | Automatic Response |
|---|---|
low | Flag for manual review; no account change |
medium | Restrict withdrawals pending review |
high | Suspend trading; require identity re-verification |
async function applyFraudResponse(signal: FraudSignal) {
const actions: Record<string, () => Promise<void>> = {
low: () => createReviewTask(signal),
medium: async () => {
await createReviewTask(signal);
await rbacService.addRestriction(signal.account_id, "withdrawal_hold");
await notifyCompliance(signal);
},
high: async () => {
await createReviewTask(signal);
await rbacService.addRestriction(signal.account_id, "trading_suspended");
await rbacService.addRestriction(signal.account_id, "withdrawal_hold");
await notifyCompliance(signal);
await notifyAccount(signal.account_id, "account_under_review");
},
};
await actions[signal.severity]();
await logSignal(signal);
}The compliance team reviews flagged accounts through an internal dashboard. They can clear the flag (removing restrictions) or escalate to account closure.
False Positive Rate
When we first shipped, our wash trade rule had a high false positive rate—legitimate scalpers trading both sides of a spread as a hedging strategy triggered it constantly. We solved this with a context layer:
- If the account has a documented "scalper" strategy on file, wash trade signals are downgraded to
lowseverity. - If total trading volume is above a threshold consistent with genuine activity (not just a few offsetting trades), the signal is suppressed.
After tuning, our false positive rate dropped from ~18% to ~3% over two months.
Key Takeaways
- Stream detection for real-time signals, batch for pattern-over-time signals—don't try to do bonus abuse detection in a stream; it needs historical context.
- Graduated responses via RBAC are more defensible than binary block/allow—they give compliance teams room to investigate before taking hard action.
- False positives are costly in trading—a legitimate trader incorrectly suspended is a real business loss. Invest in tuning before shipping to production.
- Log all signals, even the suppressed ones—pattern analysis over suppressed signals often reveals gaps in your detection rules.