Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions packages/core/src/ai/fraud-detector.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { RealTimeFraudScorer } from './fraud/riskScorer';
import { RiskResult, Transaction } from './fraud/types';

export class FraudDetector {
async detectAnomalies(transactionData: any): Promise<boolean> {
// TODO: Implement fraud detection algorithm - Issue #28
throw new Error('Not implemented yet - see issue #28');
private scorer = new RealTimeFraudScorer();

async initializeBaseline(transactions: Transaction[]): Promise<void> {
this.scorer.fitBaseline(transactions);
}

async detectAnomalies(transactionData: Transaction): Promise<boolean> {
const result = this.scorer.scoreTransaction(transactionData);
return result.riskScore >= 70; // high risk threshold
}

async getRiskFactors(transactionData: Transaction): Promise<string[]> {
const result = this.scorer.scoreTransaction(transactionData);
return result.reasons;
}

async getRiskFactors(transactionData: any): Promise<string[]> {
// TODO: Implement risk factor analysis - Issue #28
throw new Error('Not implemented yet - see issue #28');
async score(transactionData: Transaction): Promise<RiskResult> {
return this.scorer.scoreTransaction(transactionData);
}
}
128 changes: 128 additions & 0 deletions packages/core/src/ai/fraud/anomalyDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { AnomalyModel, AnomalyScore, FeatureVector } from './types';

// Lightweight, real-time friendly anomaly detectors

class IsolationForestApprox implements AnomalyModel {
private featureMins: number[] = [];
private featureMaxs: number[] = [];
private iqrLow: number[] = [];
private iqrHigh: number[] = [];
private featureCount = 0;

fit(samples: FeatureVector[]): void {
if (samples.length === 0) return;
const F = samples[0].features.length;
this.featureCount = F;
const cols: number[][] = Array.from({ length: F }, () => []);
for (const s of samples) {
for (let i = 0; i < F; i++) cols[i].push(s.features[i]);
}
this.featureMins = cols.map((c) => Math.min(...c));
this.featureMaxs = cols.map((c) => Math.max(...c));
// Compute approximate IQR bounds
this.iqrLow = cols.map((c) => quantile(c, 0.25));
this.iqrHigh = cols.map((c) => quantile(c, 0.75));
}

score(sample: FeatureVector): AnomalyScore {
const outliers: number[] = [];
for (let i = 0; i < this.featureCount; i++) {
const v = sample.features[i];
const low = this.iqrLow[i];
const high = this.iqrHigh[i];
const min = this.featureMins[i];
const max = this.featureMaxs[i];
// outside IQR scaled by range
let deviation = 0;
if (v < low) deviation = (low - v) / Math.max(1e-6, low - min);
else if (v > high) deviation = (v - high) / Math.max(1e-6, max - high);
outliers.push(Math.min(1, Math.max(0, deviation)));
}
const score = clamp(0, 1, outliers.reduce((a, b) => a + b, 0) / Math.max(1, outliers.length));
return {
score,
model: 'isolation_forest',
details: { outlierByFeature: outliers },
};
}
}

class OneClassSVMApprox implements AnomalyModel {
private means: number[] = [];
private stds: number[] = [];
private featureCount = 0;
private nu = 0.1; // approximate fraction of outliers

constructor(nu?: number) {
if (nu !== undefined) this.nu = nu;
}

fit(samples: FeatureVector[]): void {
if (samples.length === 0) return;
const F = samples[0].features.length;
this.featureCount = F;
const cols: number[][] = Array.from({ length: F }, () => []);
for (const s of samples) {
for (let i = 0; i < F; i++) cols[i].push(s.features[i]);
}
this.means = cols.map((c) => mean(c));
this.stds = cols.map((c) => std(c) || 1e-6);
}

score(sample: FeatureVector): AnomalyScore {
const z: number[] = [];
for (let i = 0; i < this.featureCount; i++) {
const v = sample.features[i];
z.push(Math.abs((v - this.means[i]) / this.stds[i]));
}
const distance = z.reduce((a, b) => a + b * b, 0); // squared z-distance
// Map distance to [0,1] via logistic based on nu
const threshold = Math.max(1e-3, invLogitTarget(this.nu));
const raw = 1 / (1 + Math.exp(-(distance - threshold)));
const score = clamp(0, 1, raw);
return {
score,
model: 'one_class_svm',
details: { zScores: z, distance },
};
}
}

function mean(arr: number[]) {
if (!arr.length) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function std(arr: number[]) {
if (arr.length < 2) return 0;
const m = mean(arr);
const v = arr.reduce((a, b) => a + (b - m) * (b - m), 0) / (arr.length - 1);
return Math.sqrt(v);
}
function clamp(min: number, max: number, v: number) {
return Math.min(max, Math.max(min, v));
}
function quantile(arr: number[], q: number) {
if (arr.length === 0) return 0;
const a = arr.slice().sort((x, y) => x - y);
const idx = Math.floor(q * (a.length - 1));
return a[idx];
}
function invLogitTarget(nu: number) {
// rough mapping: smaller nu -> lower threshold
return Math.log(1 / nu - 1);
}

export class AnomalyDetector {
private iso = new IsolationForestApprox();
private ocsvm = new OneClassSVMApprox(0.1);

fit(samples: FeatureVector[]) {
// Keep fitting lightweight to meet real-time constraints
this.iso.fit(samples);
this.ocsvm.fit(samples);
}

score(sample: FeatureVector): AnomalyScore[] {
return [this.iso.score(sample), this.ocsvm.score(sample)];
}
}
186 changes: 186 additions & 0 deletions packages/core/src/ai/fraud/featureExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { FeatureExtractorOptions, FeatureVector, Transaction } from './types';

type AccountHistory = {
lastTx?: Transaction;
txTimestamps: number[]; // recent timestamps
amounts: number[]; // recent amounts
merchantCounts: Map<string, number>;
hourHistogram: number[]; // 24 buckets
};

const EARTH_RADIUS_KM = 6371;

function haversine(lat1?: number, lon1?: number, lat2?: number, lon2?: number): number | undefined {
if (
lat1 === undefined || lon1 === undefined || lat2 === undefined || lon2 === undefined
)
return undefined;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_KM * c;
}

function mean(arr: number[]) {
if (!arr.length) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function std(arr: number[]) {
if (arr.length < 2) return 0;
const m = mean(arr);
const v = arr.reduce((a, b) => a + (b - m) * (b - m), 0) / (arr.length - 1);
return Math.sqrt(v);
}

const HIGH_RISK_COUNTRIES = new Set<string>([
// Example list, extend as necessary
'NG', 'UA', 'RU', 'IR', 'IQ', 'AF', 'SY', 'YE'
]);

export class FeatureExtractor {
private opts: Required<FeatureExtractorOptions>;
private history: Map<string, AccountHistory> = new Map();

constructor(opts?: FeatureExtractorOptions) {
this.opts = {
velocityWindowsMinutes: opts?.velocityWindowsMinutes ?? [1, 5, 60],
maxHistoryPerAccount: opts?.maxHistoryPerAccount ?? 500,
};
}

private getHistory(accountId: string): AccountHistory {
let h = this.history.get(accountId);
if (!h) {
h = {
txTimestamps: [],
amounts: [],
merchantCounts: new Map(),
hourHistogram: new Array(24).fill(0),
};
this.history.set(accountId, h);
}
return h;
}

private updateHistory(tx: Transaction) {
const h = this.getHistory(tx.accountId);
h.lastTx = tx;
h.txTimestamps.push(tx.timestamp);
h.amounts.push(tx.amount);
if (tx.merchantId) h.merchantCounts.set(tx.merchantId, (h.merchantCounts.get(tx.merchantId) || 0) + 1);
const hour = new Date(tx.timestamp).getHours();
h.hourHistogram[hour]++;
// Trim history
const max = this.opts.maxHistoryPerAccount;
if (h.txTimestamps.length > max) h.txTimestamps.splice(0, h.txTimestamps.length - max);
if (h.amounts.length > max) h.amounts.splice(0, h.amounts.length - max);
if (h.merchantCounts.size > max) {
// simple decay: reduce counts
for (const key of h.merchantCounts.keys()) {
h.merchantCounts.set(key, Math.max(0, Math.floor((h.merchantCounts.get(key) || 0) * 0.9)));
}
}
// hour histogram decay
for (let i = 0; i < 24; i++) h.hourHistogram[i] = Math.floor(h.hourHistogram[i] * 0.995);
}

private velocityCounts(h: AccountHistory, now: number) {
const counts: number[] = [];
for (const w of this.opts.velocityWindowsMinutes) {
const windowMs = w * 60 * 1000;
const c = h.txTimestamps.filter((t) => now - t <= windowMs).length;
counts.push(c);
}
return counts;
}

extract(tx: Transaction): FeatureVector {
const start = Date.now();
const h = this.getHistory(tx.accountId);
const names: string[] = [];
const values: number[] = [];

// Amount z-score vs account history
const m = mean(h.amounts);
const s = std(h.amounts) || 1e-6;
names.push('amount_z');
values.push((tx.amount - m) / s);

// Velocity features
const counts = this.velocityCounts(h, tx.timestamp);
for (let i = 0; i < counts.length; i++) {
names.push(`velocity_${this.opts.velocityWindowsMinutes[i]}m`);
values.push(counts[i]);
}

// Time since last transaction
const dt = h.lastTx ? (tx.timestamp - h.lastTx.timestamp) / 1000 : 1e6;
names.push('seconds_since_last_tx');
values.push(dt);

// Merchant novelty
const merchantFreq = tx.merchantId ? h.merchantCounts.get(tx.merchantId) || 0 : 0;
names.push('merchant_novelty');
values.push(merchantFreq === 0 ? 1 : 1 / Math.sqrt(merchantFreq + 1));

// Hour-of-day deviation
const hour = new Date(tx.timestamp).getHours();
const totalHours = h.hourHistogram.reduce((a, b) => a + b, 0) || 1;
const hourProb = h.hourHistogram[hour] / totalHours;
names.push('hour_deviation');
values.push(1 - hourProb);

// Geo-velocity km per hour
const distKm = haversine(h.lastTx?.lat, h.lastTx?.lon, tx.lat, tx.lon);
const hours = h.lastTx ? Math.max(1e-6, (tx.timestamp - h.lastTx.timestamp) / (3600 * 1000)) : 0;
const kmph = distKm !== undefined ? distKm / hours : 0;
names.push('geo_speed_kmph');
values.push(kmph);

// Device change indicator
const deviceChanged = h.lastTx?.deviceId && tx.deviceId && h.lastTx.deviceId !== tx.deviceId ? 1 : 0;
names.push('device_changed');
values.push(deviceChanged);

// Channel risk weight
const channelRisk = tx.channel === 'online' ? 1 : tx.channel === 'atm' ? 0.7 : tx.channel === 'transfer' ? 0.8 : 0.5;
names.push('channel_risk');
values.push(channelRisk);

// Country risk indicator
const countryRisk = tx.country && HIGH_RISK_COUNTRIES.has(tx.country) ? 1 : 0;
names.push('country_risk');
values.push(countryRisk);

// Balance impact ratio
const balanceImpact = tx.previousBalance ? tx.amount / Math.max(1, tx.previousBalance) : 0;
names.push('balance_impact_ratio');
values.push(balanceImpact);

// Update history after extracting features
this.updateHistory(tx);

const fv: FeatureVector = {
id: tx.id,
accountId: tx.accountId,
features: values,
featureNames: names,
};

// Ensure fast execution
const elapsed = Date.now() - start;
if (elapsed > 50) {
// If extraction slows, apply more aggressive decay to history to keep it light
const hist = this.getHistory(tx.accountId);
hist.txTimestamps = hist.txTimestamps.slice(-Math.floor(this.opts.maxHistoryPerAccount / 2));
hist.amounts = hist.amounts.slice(-Math.floor(this.opts.maxHistoryPerAccount / 2));
}

return fv;
}
}
Loading
Loading