import Decimal from 'decimal.js';
import { createSuccessResponse } from '../utils/helpers.js';
import { analyzeTechnicals, TA_THRESHOLDS } from '../indicators/technical-indicators.js';
import {
FindTASignalsSchema,
ScanSpreadsSchema,
ScanBestOpportunitiesSchema
} from '../schemas/index.js';
import { z } from 'zod';
const SCANNER_DEFAULTS = {
MIN_VOLUME_USDT: 50000,
MAX_PAIRS: 100,
MIN_CONFIDENCE: 50
};
export function createScannerTools(exchange, mode) {
return {
find_ta_signals: {
description: 'Scan trading pairs for technical analysis signals. Returns buy/sell opportunities ranked by confidence.',
inputSchema: FindTASignalsSchema.extend({
signalType: z.enum(['buy', 'sell', 'any']).optional().default('any'),
limit: z.number().min(1).max(50).optional().default(10)
}),
handler: async (input) => {
const minVolumeUsdt = input.minVolume || SCANNER_DEFAULTS.MIN_VOLUME_USDT;
const maxPairs = input.maxPairs || SCANNER_DEFAULTS.MAX_PAIRS;
const timeframe = input.timeframe || '1h';
const minConfidence = input.minConfidence || SCANNER_DEFAULTS.MIN_CONFIDENCE;
const signalType = input.signalType || 'any';
const limit = input.limit || 10;
const quoteAsset = input.quoteAsset || 'USDT';
const allTickers = await exchange.getAllTickers(quoteAsset);
const filtered = allTickers
.filter(t => new Decimal(t.volume24h).gte(minVolumeUsdt))
.slice(0, maxPairs);
if (filtered.length === 0) {
return createSuccessResponse({
signals: [],
count: 0,
message: `No pairs found with volume >= ${minVolumeUsdt} ${quoteAsset}`
}, mode);
}
const analyses = await Promise.all(
filtered.map(async (ticker) => {
try {
const ohlcv = await exchange.getOHLCV(ticker.symbol, timeframe, 100);
if (!ohlcv || ohlcv.length < TA_THRESHOLDS.MIN_CANDLES) {
return null;
}
const analysis = analyzeTechnicals(ohlcv);
return {
symbol: ticker.symbol,
...analysis,
volume24h: ticker.volume24h,
spreadPercent: ticker.spreadPercent
};
} catch (error) {
console.error(`Failed to analyze ${ticker.symbol}:`, error.message);
return null;
}
})
);
let signals = analyses
.filter(a => a !== null)
.filter(a => a.recommendation.action !== 'hold')
.filter(a => a.recommendation.confidence >= minConfidence);
if (signalType !== 'any') {
signals = signals.filter(s => s.recommendation.action === signalType);
}
signals.sort((a, b) => b.recommendation.confidence - a.recommendation.confidence);
signals = signals.slice(0, limit);
return createSuccessResponse({
signals,
count: signals.length,
totalPairs: allTickers.length,
analyzedPairs: filtered.length,
timeframe,
timestamp: new Date().toISOString()
}, mode);
}
},
scan_spreads: {
description: 'Scan for wide bid-ask spreads across trading pairs. Useful for market-making opportunities.',
inputSchema: ScanSpreadsSchema.extend({
limit: z.number().min(1).max(100).optional().default(20)
}),
handler: async (input) => {
const minSpreadPercent = input.minSpread || 0.1;
const minVolumeUsdt = input.minVolume || SCANNER_DEFAULTS.MIN_VOLUME_USDT;
const limit = input.limit || 20;
const quoteAsset = input.quoteAsset || 'USDT';
const allTickers = await exchange.getAllTickers(quoteAsset);
let opportunities = allTickers
.filter(t => new Decimal(t.volume24h).gte(minVolumeUsdt))
.filter(t => new Decimal(t.spreadPercent).gte(minSpreadPercent))
.sort((a, b) => parseFloat(b.spreadPercent) - parseFloat(a.spreadPercent))
.slice(0, limit)
.map(t => ({
symbol: t.symbol,
bid: t.bid,
ask: t.ask,
spreadPercent: t.spreadPercent,
volume24h: t.volume24h,
last: t.last
}));
return createSuccessResponse({
opportunities,
count: opportunities.length,
totalPairs: allTickers.length,
filters: { minSpreadPercent, minVolumeUsdt },
timestamp: new Date().toISOString()
}, mode);
}
},
scan_best_opportunities: {
description: 'Comprehensive market scan combining spread, volume, and TA signals. Returns the best trading opportunities.',
inputSchema: ScanBestOpportunitiesSchema.extend({
signalType: z.enum(['buy', 'sell', 'any']).optional().default('any'),
sortBy: z.enum(['confidence', 'volume', 'spread', 'score']).optional().default('score')
}),
handler: async (input) => {
const minVolumeUsdt = input.minVolume || SCANNER_DEFAULTS.MIN_VOLUME_USDT;
const minSpreadPercent = input.minSpread || 0;
const signalType = input.signalType || 'any';
const minConfidence = input.minTAConfidence || SCANNER_DEFAULTS.MIN_CONFIDENCE;
const timeframe = input.timeframe || '1h';
const limit = input.maxResults || 10;
const sortBy = input.sortBy || 'score';
const quoteAsset = input.quoteAsset || 'USDT';
const allTickers = await exchange.getAllTickers(quoteAsset);
const filtered = allTickers
.filter(t => new Decimal(t.volume24h).gte(minVolumeUsdt))
.filter(t => new Decimal(t.spreadPercent).gte(minSpreadPercent))
.slice(0, 100);
if (filtered.length === 0) {
return createSuccessResponse({
opportunities: [],
count: 0,
message: 'No pairs match the filter criteria'
}, mode);
}
const analyses = await Promise.all(
filtered.map(async (ticker) => {
try {
const ohlcv = await exchange.getOHLCV(ticker.symbol, timeframe, 100);
if (!ohlcv || ohlcv.length < TA_THRESHOLDS.MIN_CANDLES) {
return null;
}
const ta = analyzeTechnicals(ohlcv);
const volume = new Decimal(ticker.volume24h);
const volumeScore = Math.min(volume.div(1000000).toNumber(), 100);
const spreadScore = Math.min(new Decimal(ticker.spreadPercent).times(50).toNumber(), 50);
const taScore = ta.recommendation.confidence;
const combinedScore = (volumeScore * 0.2) + (spreadScore * 0.3) + (taScore * 0.5);
return {
symbol: ticker.symbol,
action: ta.recommendation.action,
confidence: ta.recommendation.confidence,
currentPrice: ticker.last,
bid: ticker.bid,
ask: ticker.ask,
spreadPercent: ticker.spreadPercent,
volume24h: ticker.volume24h,
reason: ta.recommendation.reason,
scores: {
volume: volumeScore.toFixed(1),
spread: spreadScore.toFixed(1),
ta: taScore.toFixed(1),
combined: combinedScore.toFixed(1)
}
};
} catch (error) {
console.error(`Failed to analyze ${ticker.symbol}:`, error.message);
return null;
}
})
);
let opportunities = analyses.filter(a => a !== null);
if (signalType !== 'any') {
opportunities = opportunities.filter(o => o.action === signalType);
} else {
opportunities = opportunities.filter(o => o.action !== 'hold' || new Decimal(o.spreadPercent).gt(0.1));
}
opportunities = opportunities.filter(o =>
o.confidence >= minConfidence || new Decimal(o.spreadPercent).gt(0.1)
);
const sortFunctions = {
confidence: (a, b) => b.confidence - a.confidence,
volume: (a, b) => parseFloat(b.volume24h) - parseFloat(a.volume24h),
spread: (a, b) => parseFloat(b.spreadPercent) - parseFloat(a.spreadPercent),
score: (a, b) => parseFloat(b.scores.combined) - parseFloat(a.scores.combined)
};
opportunities.sort(sortFunctions[sortBy]);
opportunities = opportunities.slice(0, limit);
return createSuccessResponse({
opportunities,
count: opportunities.length,
totalPairs: allTickers.length,
analyzedPairs: filtered.length,
filters: { minVolumeUsdt, minSpreadPercent, signalType, minConfidence },
sortedBy: sortBy,
timeframe,
timestamp: new Date().toISOString()
}, mode);
}
}
};
}