Initial commit: Mantle AI Trading Bot
Features: - AI-powered signal generation with multi-factor analysis - Fundamental news aggregation from multiple sources - Technical analysis with 6+ indicators - VectorDB integration for semantic search - Backtesting engine with performance metrics - Demo/paper trading mode - Real-time WebSocket updates - Comprehensive dashboard UI Built for Mantle Turing Test Hackathon - AI Trading track - AI Alpha & Data track
This commit is contained in:
13
src/lib/db.ts
Normal file
13
src/lib/db.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||
466
src/lib/trading/backtest/backtest-engine.ts
Normal file
466
src/lib/trading/backtest/backtest-engine.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Backtesting Engine for Mantle AI Trading Bot
|
||||
* Simulates trading strategies on historical data
|
||||
*/
|
||||
|
||||
import {
|
||||
BacktestConfig,
|
||||
BacktestResult,
|
||||
BacktestSession,
|
||||
PerformanceMetrics,
|
||||
Signal,
|
||||
TradeAction,
|
||||
OrderType,
|
||||
MarketDataPoint,
|
||||
TimeFrame
|
||||
} from '../core/types';
|
||||
import { signalEngine } from '../signals/signal-engine';
|
||||
|
||||
export class BacktestEngine {
|
||||
private trades: BacktestResult[] = [];
|
||||
private equityCurve: number[] = [];
|
||||
private currentCapital: number = 0;
|
||||
|
||||
/**
|
||||
* Run a backtest session
|
||||
*/
|
||||
async runBacktest(config: BacktestConfig): Promise<BacktestSession> {
|
||||
const session: BacktestSession = {
|
||||
id: `backtest-${Date.now()}`,
|
||||
name: config.name,
|
||||
symbol: config.symbol,
|
||||
startDate: config.startDate,
|
||||
endDate: config.endDate,
|
||||
initialCapital: config.initialCapital,
|
||||
totalTrades: 0,
|
||||
status: 'RUNNING',
|
||||
results: []
|
||||
};
|
||||
|
||||
this.trades = [];
|
||||
this.equityCurve = [];
|
||||
this.currentCapital = config.initialCapital;
|
||||
|
||||
try {
|
||||
// Generate simulated historical data (in production, fetch from API)
|
||||
const historicalData = await this.generateHistoricalData(
|
||||
config.symbol,
|
||||
config.startDate,
|
||||
config.endDate
|
||||
);
|
||||
|
||||
// Process each data point
|
||||
for (let i = 50; i < historicalData.length; i++) {
|
||||
const windowData = historicalData.slice(0, i);
|
||||
const currentPrice = historicalData[i].close;
|
||||
|
||||
// Generate signal
|
||||
const signalOutput = await signalEngine.generateSignal({
|
||||
symbol: config.symbol,
|
||||
timeframe: TimeFrame.ONE_HOUR,
|
||||
marketData: windowData,
|
||||
newsArticles: []
|
||||
});
|
||||
|
||||
// Check if we should trade
|
||||
if (signalOutput.signal.action !== TradeAction.HOLD) {
|
||||
await this.processSignal(
|
||||
signalOutput.signal,
|
||||
currentPrice,
|
||||
config,
|
||||
historicalData.slice(i)
|
||||
);
|
||||
}
|
||||
|
||||
// Update equity curve
|
||||
this.equityCurve.push(this.currentCapital);
|
||||
}
|
||||
|
||||
// Calculate final metrics
|
||||
const metrics = this.calculatePerformanceMetrics(
|
||||
config.initialCapital,
|
||||
this.currentCapital
|
||||
);
|
||||
|
||||
// Update session with results
|
||||
session.status = 'COMPLETED';
|
||||
session.finalCapital = this.currentCapital;
|
||||
session.totalTrades = this.trades.length;
|
||||
session.winRate = metrics.winRate;
|
||||
session.maxDrawdown = metrics.maxDrawdown;
|
||||
session.sharpeRatio = metrics.sharpeRatio;
|
||||
session.results = this.trades;
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
session.status = 'FAILED';
|
||||
console.error('Backtest failed:', error);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a trading signal in backtest
|
||||
*/
|
||||
private async processSignal(
|
||||
signal: Omit<Signal, 'id' | 'createdAt' | 'updatedAt'>,
|
||||
currentPrice: number,
|
||||
config: BacktestConfig,
|
||||
futureData: MarketDataPoint[]
|
||||
): Promise<void> {
|
||||
// Calculate position size
|
||||
const riskPerTrade = config.parameters.riskPerTrade || 0.02; // 2% risk
|
||||
const maxPosition = this.currentCapital * riskPerTrade;
|
||||
const quantity = maxPosition / currentPrice;
|
||||
|
||||
// Apply slippage
|
||||
const slippage = config.slippage || 0.001;
|
||||
const entryPrice = signal.action === TradeAction.BUY
|
||||
? currentPrice * (1 + slippage)
|
||||
: currentPrice * (1 - slippage);
|
||||
|
||||
// Find exit point
|
||||
let exitPrice: number | undefined;
|
||||
let exitTime: Date | undefined;
|
||||
let exitReason = 'Manual';
|
||||
|
||||
const stopLoss = signal.stopLoss || entryPrice * 0.95;
|
||||
const takeProfit = signal.takeProfit || entryPrice * 1.05;
|
||||
|
||||
for (const dataPoint of futureData) {
|
||||
const high = dataPoint.high;
|
||||
const low = dataPoint.low;
|
||||
|
||||
// Check stop loss
|
||||
if (signal.action === TradeAction.BUY && low <= stopLoss) {
|
||||
exitPrice = stopLoss * (1 - slippage);
|
||||
exitTime = dataPoint.timestamp;
|
||||
exitReason = 'Stop Loss';
|
||||
break;
|
||||
}
|
||||
|
||||
if (signal.action === TradeAction.SELL && high >= stopLoss) {
|
||||
exitPrice = stopLoss * (1 + slippage);
|
||||
exitTime = dataPoint.timestamp;
|
||||
exitReason = 'Stop Loss';
|
||||
break;
|
||||
}
|
||||
|
||||
// Check take profit
|
||||
if (signal.action === TradeAction.BUY && high >= takeProfit) {
|
||||
exitPrice = takeProfit * (1 - slippage);
|
||||
exitTime = dataPoint.timestamp;
|
||||
exitReason = 'Take Profit';
|
||||
break;
|
||||
}
|
||||
|
||||
if (signal.action === TradeAction.SELL && low <= takeProfit) {
|
||||
exitPrice = takeProfit * (1 + slippage);
|
||||
exitTime = dataPoint.timestamp;
|
||||
exitReason = 'Take Profit';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exit found, close at last price
|
||||
if (!exitPrice && futureData.length > 0) {
|
||||
const lastCandle = futureData[futureData.length - 1];
|
||||
exitPrice = lastCandle.close;
|
||||
exitTime = lastCandle.timestamp;
|
||||
exitReason = 'End of Period';
|
||||
}
|
||||
|
||||
// Calculate PnL
|
||||
if (exitPrice) {
|
||||
const fees = config.fees || 0.001;
|
||||
const feeAmount = (entryPrice * quantity * fees) + (exitPrice * quantity * fees);
|
||||
|
||||
let pnl: number;
|
||||
if (signal.action === TradeAction.BUY) {
|
||||
pnl = (exitPrice - entryPrice) * quantity - feeAmount;
|
||||
} else {
|
||||
pnl = (entryPrice - exitPrice) * quantity - feeAmount;
|
||||
}
|
||||
|
||||
const pnlPercent = pnl / (entryPrice * quantity);
|
||||
|
||||
// Update capital
|
||||
this.currentCapital += pnl;
|
||||
|
||||
// Record trade
|
||||
this.trades.push({
|
||||
id: `trade-${this.trades.length}`,
|
||||
sessionId: '',
|
||||
symbol: signal.symbol,
|
||||
action: signal.action,
|
||||
entryPrice,
|
||||
exitPrice,
|
||||
quantity,
|
||||
pnl,
|
||||
pnlPercent,
|
||||
executedAt: new Date(),
|
||||
closedAt: exitTime,
|
||||
notes: exitReason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate simulated historical data
|
||||
* In production, this would fetch real data from Bybit API
|
||||
*/
|
||||
private async generateHistoricalData(
|
||||
symbol: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<MarketDataPoint[]> {
|
||||
const data: MarketDataPoint[] = [];
|
||||
const start = startDate.getTime();
|
||||
const end = endDate.getTime();
|
||||
const hour = 60 * 60 * 1000;
|
||||
|
||||
// Generate realistic price movements
|
||||
let price = 40000 + Math.random() * 10000; // Starting price
|
||||
|
||||
for (let timestamp = start; timestamp <= end; timestamp += hour) {
|
||||
// Random walk with drift
|
||||
const change = (Math.random() - 0.48) * price * 0.02; // Slight upward bias
|
||||
const open = price;
|
||||
const close = price + change;
|
||||
const high = Math.max(open, close) + Math.random() * Math.abs(change) * 0.5;
|
||||
const low = Math.min(open, close) - Math.random() * Math.abs(change) * 0.5;
|
||||
const volume = 1000 + Math.random() * 5000;
|
||||
|
||||
data.push({
|
||||
symbol,
|
||||
timeframe: TimeFrame.ONE_HOUR,
|
||||
timestamp: new Date(timestamp),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
});
|
||||
|
||||
price = close;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance metrics
|
||||
*/
|
||||
private calculatePerformanceMetrics(
|
||||
initialCapital: number,
|
||||
finalCapital: number
|
||||
): PerformanceMetrics {
|
||||
const winningTrades = this.trades.filter(t => (t.pnl || 0) > 0);
|
||||
const losingTrades = this.trades.filter(t => (t.pnl || 0) <= 0);
|
||||
|
||||
const totalReturn = ((finalCapital - initialCapital) / initialCapital) * 100;
|
||||
|
||||
// Calculate annualized return (assuming 365 days)
|
||||
const days = 365;
|
||||
const annualizedReturn = ((Math.pow(finalCapital / initialCapital, 365 / days) - 1) * 100);
|
||||
|
||||
// Calculate Sharpe Ratio
|
||||
const returns = this.trades.map(t => t.pnlPercent || 0);
|
||||
const avgReturn = returns.reduce((a, b) => a + b, 0) / returns.length || 0;
|
||||
const stdDev = Math.sqrt(
|
||||
returns.reduce((sum, r) => sum + Math.pow(r - avgReturn, 2), 0) / returns.length
|
||||
) || 0;
|
||||
const sharpeRatio = stdDev > 0 ? (avgReturn / stdDev) * Math.sqrt(252) : 0;
|
||||
|
||||
// Calculate Sortino Ratio (downside deviation)
|
||||
const downsideReturns = returns.filter(r => r < 0);
|
||||
const downsideDev = Math.sqrt(
|
||||
downsideReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / downsideReturns.length
|
||||
) || 0;
|
||||
const sortinoRatio = downsideDev > 0 ? (avgReturn / downsideDev) * Math.sqrt(252) : 0;
|
||||
|
||||
// Calculate max drawdown
|
||||
let maxDrawdown = 0;
|
||||
let peak = this.equityCurve[0] || initialCapital;
|
||||
|
||||
for (const equity of this.equityCurve) {
|
||||
if (equity > peak) {
|
||||
peak = equity;
|
||||
}
|
||||
const drawdown = (peak - equity) / peak;
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown;
|
||||
}
|
||||
}
|
||||
|
||||
// Win rate
|
||||
const winRate = this.trades.length > 0
|
||||
? winningTrades.length / this.trades.length
|
||||
: 0;
|
||||
|
||||
// Profit factor
|
||||
const grossProfit = winningTrades.reduce((sum, t) => sum + (t.pnl || 0), 0);
|
||||
const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + (t.pnl || 0), 0));
|
||||
const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : 0;
|
||||
|
||||
// Average win/loss
|
||||
const averageWin = winningTrades.length > 0
|
||||
? grossProfit / winningTrades.length
|
||||
: 0;
|
||||
const averageLoss = losingTrades.length > 0
|
||||
? grossLoss / losingTrades.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalReturn,
|
||||
annualizedReturn,
|
||||
sharpeRatio,
|
||||
sortinoRatio,
|
||||
maxDrawdown: maxDrawdown * 100,
|
||||
winRate: winRate * 100,
|
||||
profitFactor,
|
||||
averageWin,
|
||||
averageLoss,
|
||||
totalTrades: this.trades.length,
|
||||
winningTrades: winningTrades.length,
|
||||
losingTrades: losingTrades.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize strategy parameters
|
||||
*/
|
||||
async optimizeStrategy(
|
||||
symbol: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
parameterRanges: Record<string, number[]>
|
||||
): Promise<{
|
||||
bestParameters: Record<string, number>;
|
||||
bestPerformance: PerformanceMetrics;
|
||||
allResults: Array<{ parameters: Record<string, number>; metrics: PerformanceMetrics }>;
|
||||
}> {
|
||||
const results: Array<{ parameters: Record<string, number>; metrics: PerformanceMetrics }> = [];
|
||||
|
||||
// Generate all parameter combinations
|
||||
const combinations = this.generateCombinations(parameterRanges);
|
||||
|
||||
for (const params of combinations) {
|
||||
const config: BacktestConfig = {
|
||||
name: `Optimization ${JSON.stringify(params)}`,
|
||||
symbol,
|
||||
startDate,
|
||||
endDate,
|
||||
initialCapital: 10000,
|
||||
strategy: 'default',
|
||||
parameters: params,
|
||||
fees: 0.001,
|
||||
slippage: 0.001
|
||||
};
|
||||
|
||||
const session = await this.runBacktest(config);
|
||||
|
||||
if (session.status === 'COMPLETED') {
|
||||
const metrics = this.calculatePerformanceMetrics(
|
||||
config.initialCapital,
|
||||
session.finalCapital || config.initialCapital
|
||||
);
|
||||
|
||||
results.push({ parameters: params, metrics });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by Sharpe ratio
|
||||
results.sort((a, b) => b.metrics.sharpeRatio - a.metrics.sharpeRatio);
|
||||
|
||||
return {
|
||||
bestParameters: results[0]?.parameters || {},
|
||||
bestPerformance: results[0]?.metrics || this.getEmptyMetrics(),
|
||||
allResults: results
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate parameter combinations
|
||||
*/
|
||||
private generateCombinations(
|
||||
ranges: Record<string, number[]>
|
||||
): Record<string, number>[] {
|
||||
const keys = Object.keys(ranges);
|
||||
if (keys.length === 0) return [{}];
|
||||
|
||||
const result: Record<string, number>[] = [];
|
||||
const [firstKey, ...restKeys] = keys;
|
||||
|
||||
for (const value of ranges[firstKey]) {
|
||||
const restCombinations = this.generateCombinations(
|
||||
Object.fromEntries(restKeys.map(k => [k, ranges[k]]))
|
||||
);
|
||||
|
||||
for (const rest of restCombinations) {
|
||||
result.push({ [firstKey]: value, ...rest });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty metrics
|
||||
*/
|
||||
private getEmptyMetrics(): PerformanceMetrics {
|
||||
return {
|
||||
totalReturn: 0,
|
||||
annualizedReturn: 0,
|
||||
sharpeRatio: 0,
|
||||
sortinoRatio: 0,
|
||||
maxDrawdown: 0,
|
||||
winRate: 0,
|
||||
profitFactor: 0,
|
||||
averageWin: 0,
|
||||
averageLoss: 0,
|
||||
totalTrades: 0,
|
||||
winningTrades: 0,
|
||||
losingTrades: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backtest report
|
||||
*/
|
||||
generateReport(session: BacktestSession): string {
|
||||
const metrics = this.calculatePerformanceMetrics(
|
||||
session.initialCapital,
|
||||
session.finalCapital || session.initialCapital
|
||||
);
|
||||
|
||||
return `
|
||||
# Backtest Report: ${session.name}
|
||||
|
||||
## Summary
|
||||
- Symbol: ${session.symbol}
|
||||
- Period: ${session.startDate.toDateString()} - ${session.endDate.toDateString()}
|
||||
- Initial Capital: $${session.initialCapital.toLocaleString()}
|
||||
- Final Capital: $${(session.finalCapital || session.initialCapital).toLocaleString()}
|
||||
|
||||
## Performance Metrics
|
||||
- Total Return: ${metrics.totalReturn.toFixed(2)}%
|
||||
- Annualized Return: ${metrics.annualizedReturn.toFixed(2)}%
|
||||
- Sharpe Ratio: ${metrics.sharpeRatio.toFixed(2)}
|
||||
- Sortino Ratio: ${metrics.sortinoRatio.toFixed(2)}
|
||||
- Max Drawdown: ${metrics.maxDrawdown.toFixed(2)}%
|
||||
- Win Rate: ${metrics.winRate.toFixed(2)}%
|
||||
- Profit Factor: ${metrics.profitFactor.toFixed(2)}
|
||||
|
||||
## Trade Statistics
|
||||
- Total Trades: ${metrics.totalTrades}
|
||||
- Winning Trades: ${metrics.winningTrades}
|
||||
- Losing Trades: ${metrics.losingTrades}
|
||||
- Average Win: $${metrics.averageWin.toFixed(2)}
|
||||
- Average Loss: $${metrics.averageLoss.toFixed(2)}
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const backtestEngine = new BacktestEngine();
|
||||
654
src/lib/trading/core/trading-engine.ts
Normal file
654
src/lib/trading/core/trading-engine.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* Bybit Trading Client for Mantle AI Trading Bot
|
||||
* Handles all exchange interactions including orders, positions, and market data
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
TradingConfig,
|
||||
Order,
|
||||
Position,
|
||||
Ticker,
|
||||
MarketDataPoint,
|
||||
OrderBook,
|
||||
TradeAction,
|
||||
OrderType,
|
||||
OrderStatus,
|
||||
TimeFrame,
|
||||
BybitTickerResponse,
|
||||
BybitKlineResponse,
|
||||
BybitOrderResponse,
|
||||
BybitPositionResponse,
|
||||
APIResponse
|
||||
} from './types';
|
||||
|
||||
// Bybit API endpoints
|
||||
const BYBIT_ENDPOINTS = {
|
||||
mainnet: {
|
||||
spot: 'https://api.bybit.com',
|
||||
futures: 'https://api.bybit.com'
|
||||
},
|
||||
testnet: {
|
||||
spot: 'https://api-testnet.bybit.com',
|
||||
futures: 'https://api-testnet.bybit.com'
|
||||
}
|
||||
};
|
||||
|
||||
export class BybitClient {
|
||||
private client: AxiosInstance;
|
||||
private config: TradingConfig;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(config: TradingConfig) {
|
||||
this.config = config;
|
||||
this.baseUrl = config.testnet
|
||||
? BYBIT_ENDPOINTS.testnet.futures
|
||||
: BYBIT_ENDPOINTS.mainnet.futures;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signature for authenticated requests
|
||||
*/
|
||||
private generateSignature(params: Record<string, unknown>, timestamp: number): string {
|
||||
const queryString = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
const signString = `${timestamp}${this.config.apiKey}5000${queryString}`;
|
||||
return crypto
|
||||
.createHmac('sha256', this.config.apiSecret)
|
||||
.update(signString)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated request to Bybit API
|
||||
*/
|
||||
private async authenticatedRequest<T>(
|
||||
method: 'GET' | 'POST',
|
||||
endpoint: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<APIResponse<T>> {
|
||||
const timestamp = Date.now();
|
||||
const signature = this.generateSignature(params, timestamp);
|
||||
|
||||
const headers = {
|
||||
'X-BAPI-API-KEY': this.config.apiKey,
|
||||
'X-BAPI-TIMESTAMP': timestamp,
|
||||
'X-BAPI-SIGN': signature,
|
||||
'X-BAPI-RECV-WINDOW': '5000'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = method === 'GET'
|
||||
? await this.client.get(endpoint, { params, headers })
|
||||
: await this.client.post(endpoint, params, { headers });
|
||||
|
||||
return {
|
||||
success: response.data.retCode === 0,
|
||||
data: response.data.result,
|
||||
message: response.data.retMsg
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make public request to Bybit API
|
||||
*/
|
||||
private async publicRequest<T>(
|
||||
method: 'GET' | 'POST',
|
||||
endpoint: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<APIResponse<T>> {
|
||||
try {
|
||||
const response = method === 'GET'
|
||||
? await this.client.get(endpoint, { params })
|
||||
: await this.client.post(endpoint, params);
|
||||
|
||||
return {
|
||||
success: response.data.retCode === 0,
|
||||
data: response.data.result,
|
||||
message: response.data.retMsg
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MARKET DATA ====================
|
||||
|
||||
/**
|
||||
* Get ticker information for a symbol
|
||||
*/
|
||||
async getTicker(symbol: string): Promise<Ticker | null> {
|
||||
const response = await this.publicRequest<BybitTickerResponse[]>(
|
||||
'GET',
|
||||
'/v5/market/tickers',
|
||||
{ category: 'linear', symbol }
|
||||
);
|
||||
|
||||
if (!response.success || !response.data?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = response.data[0];
|
||||
return {
|
||||
symbol: data.symbol,
|
||||
lastPrice: parseFloat(data.lastPrice),
|
||||
bidPrice: parseFloat(data.bid1Price),
|
||||
askPrice: parseFloat(data.ask1Price),
|
||||
bidQty: parseFloat(data.bid1Size),
|
||||
askQty: parseFloat(data.ask1Size),
|
||||
volume24h: parseFloat(data.volume24h),
|
||||
priceChange24h: parseFloat(data.price24hPcnt) * parseFloat(data.lastPrice),
|
||||
priceChangePercent24h: parseFloat(data.price24hPcnt) * 100,
|
||||
highPrice24h: parseFloat(data.highPrice24h),
|
||||
lowPrice24h: parseFloat(data.lowPrice24h),
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple tickers
|
||||
*/
|
||||
async getTickers(symbols: string[]): Promise<Ticker[]> {
|
||||
const tickers: Ticker[] = [];
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const ticker = await this.getTicker(symbol);
|
||||
if (ticker) tickers.push(ticker);
|
||||
}
|
||||
|
||||
return tickers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get kline/candlestick data
|
||||
*/
|
||||
async getKlines(
|
||||
symbol: string,
|
||||
timeframe: TimeFrame,
|
||||
limit: number = 200,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<MarketDataPoint[]> {
|
||||
const intervalMap: Record<TimeFrame, string> = {
|
||||
[TimeFrame.ONE_MINUTE]: '1',
|
||||
[TimeFrame.FIVE_MINUTES]: '5',
|
||||
[TimeFrame.FIFTEEN_MINUTES]: '15',
|
||||
[TimeFrame.ONE_HOUR]: '60',
|
||||
[TimeFrame.FOUR_HOURS]: '240',
|
||||
[TimeFrame.ONE_DAY]: 'D',
|
||||
[TimeFrame.ONE_WEEK]: 'W'
|
||||
};
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
category: 'linear',
|
||||
symbol,
|
||||
interval: intervalMap[timeframe],
|
||||
limit
|
||||
};
|
||||
|
||||
if (startTime) params.start = startTime;
|
||||
if (endTime) params.end = endTime;
|
||||
|
||||
const response = await this.publicRequest<BybitKlineResponse[]>(
|
||||
'GET',
|
||||
'/v5/market/kline',
|
||||
params
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.map((kline): MarketDataPoint => ({
|
||||
symbol,
|
||||
timeframe,
|
||||
timestamp: new Date(kline.startTime),
|
||||
open: parseFloat(kline.openPrice),
|
||||
high: parseFloat(kline.highPrice),
|
||||
low: parseFloat(kline.lowPrice),
|
||||
close: parseFloat(kline.closePrice),
|
||||
volume: parseFloat(kline.volume)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order book
|
||||
*/
|
||||
async getOrderBook(symbol: string, limit: number = 50): Promise<OrderBook | null> {
|
||||
const response = await this.publicRequest<{
|
||||
b: [string, string][];
|
||||
a: [string, string][];
|
||||
}>(
|
||||
'GET',
|
||||
'/v5/market/orderbook',
|
||||
{ category: 'linear', symbol, limit }
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
symbol,
|
||||
bids: response.data.b.map(([price, qty]) => ({
|
||||
price: parseFloat(price),
|
||||
quantity: parseFloat(qty)
|
||||
})),
|
||||
asks: response.data.a.map(([price, qty]) => ({
|
||||
price: parseFloat(price),
|
||||
quantity: parseFloat(qty)
|
||||
})),
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== ORDER MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
* Place a new order
|
||||
*/
|
||||
async placeOrder(params: {
|
||||
symbol: string;
|
||||
side: TradeAction;
|
||||
orderType: OrderType;
|
||||
quantity: number;
|
||||
price?: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
leverage?: number;
|
||||
positionIdx?: number;
|
||||
}): Promise<Order | null> {
|
||||
const sideMap: Record<TradeAction, string> = {
|
||||
[TradeAction.BUY]: 'Buy',
|
||||
[TradeAction.SELL]: 'Sell',
|
||||
[TradeAction.HOLD]: 'Buy', // Should not be used
|
||||
[TradeAction.CLOSE]: 'Sell' // Close position
|
||||
};
|
||||
|
||||
const orderTypeMap: Record<OrderType, string> = {
|
||||
[OrderType.MARKET]: 'Market',
|
||||
[OrderType.LIMIT]: 'Limit',
|
||||
[OrderType.STOP_MARKET]: 'Market',
|
||||
[OrderType.STOP_LIMIT]: 'Limit'
|
||||
};
|
||||
|
||||
const requestParams: Record<string, unknown> = {
|
||||
category: 'linear',
|
||||
symbol: params.symbol,
|
||||
side: sideMap[params.side],
|
||||
orderType: orderTypeMap[params.orderType],
|
||||
qty: params.quantity.toString(),
|
||||
positionIdx: params.positionIdx || 0
|
||||
};
|
||||
|
||||
if (params.price && params.orderType !== OrderType.MARKET) {
|
||||
requestParams.price = params.price.toString();
|
||||
}
|
||||
|
||||
if (params.stopLoss) {
|
||||
requestParams.stopLoss = params.stopLoss.toString();
|
||||
}
|
||||
|
||||
if (params.takeProfit) {
|
||||
requestParams.takeProfit = params.takeProfit.toString();
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if (params.leverage && params.leverage !== 1) {
|
||||
await this.setLeverage(params.symbol, params.leverage);
|
||||
}
|
||||
|
||||
const response = await this.authenticatedRequest<BybitOrderResponse>(
|
||||
'POST',
|
||||
'/v5/order/create',
|
||||
requestParams
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
console.error('Order placement failed:', response.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
return {
|
||||
id: data.orderId,
|
||||
symbol: data.symbol,
|
||||
side: params.side,
|
||||
type: params.orderType,
|
||||
quantity: params.quantity,
|
||||
price: params.price,
|
||||
status: OrderStatus.PENDING,
|
||||
leverage: params.leverage || 1,
|
||||
stopLoss: params.stopLoss,
|
||||
takeProfit: params.takeProfit,
|
||||
orderId: data.orderId,
|
||||
filledQuantity: 0,
|
||||
fees: 0,
|
||||
demo: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order
|
||||
*/
|
||||
async cancelOrder(symbol: string, orderId: string): Promise<boolean> {
|
||||
const response = await this.authenticatedRequest(
|
||||
'POST',
|
||||
'/v5/order/cancel',
|
||||
{
|
||||
category: 'linear',
|
||||
symbol,
|
||||
orderId
|
||||
}
|
||||
);
|
||||
|
||||
return response.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all orders for a symbol
|
||||
*/
|
||||
async cancelAllOrders(symbol?: string): Promise<boolean> {
|
||||
const params: Record<string, unknown> = {
|
||||
category: 'linear'
|
||||
};
|
||||
|
||||
if (symbol) {
|
||||
params.symbol = symbol;
|
||||
}
|
||||
|
||||
const response = await this.authenticatedRequest(
|
||||
'POST',
|
||||
'/v5/order/cancel-all',
|
||||
params
|
||||
);
|
||||
|
||||
return response.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order details
|
||||
*/
|
||||
async getOrder(symbol: string, orderId: string): Promise<Order | null> {
|
||||
const response = await this.authenticatedRequest<BybitOrderResponse>(
|
||||
'GET',
|
||||
'/v5/order/realtime',
|
||||
{
|
||||
category: 'linear',
|
||||
symbol,
|
||||
orderId
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
return {
|
||||
id: data.orderId,
|
||||
symbol: data.symbol,
|
||||
side: data.side === 'Buy' ? TradeAction.BUY : TradeAction.SELL,
|
||||
type: data.orderType === 'Market' ? OrderType.MARKET : OrderType.LIMIT,
|
||||
quantity: parseFloat(data.qty),
|
||||
price: parseFloat(data.price),
|
||||
status: this.mapOrderStatus(data.orderStatus),
|
||||
leverage: 1,
|
||||
orderId: data.orderId,
|
||||
executedPrice: parseFloat(data.avgPrice || '0') || undefined,
|
||||
executedAt: data.updatedTime ? new Date(parseInt(data.updatedTime)) : undefined,
|
||||
filledQuantity: parseFloat(data.cumExecQty),
|
||||
fees: parseFloat(data.cumExecFee || '0'),
|
||||
demo: false,
|
||||
createdAt: new Date(parseInt(data.createdTime)),
|
||||
updatedAt: new Date(parseInt(data.updatedTime))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open orders
|
||||
*/
|
||||
async getOpenOrders(symbol?: string): Promise<Order[]> {
|
||||
const params: Record<string, unknown> = {
|
||||
category: 'linear',
|
||||
openOnly: 0
|
||||
};
|
||||
|
||||
if (symbol) {
|
||||
params.symbol = symbol;
|
||||
}
|
||||
|
||||
const response = await this.authenticatedRequest<{ list: BybitOrderResponse[] }>(
|
||||
'GET',
|
||||
'/v5/order/realtime',
|
||||
params
|
||||
);
|
||||
|
||||
if (!response.success || !response.data?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.list.map(data => ({
|
||||
id: data.orderId,
|
||||
symbol: data.symbol,
|
||||
side: data.side === 'Buy' ? TradeAction.BUY : TradeAction.SELL,
|
||||
type: data.orderType === 'Market' ? OrderType.MARKET : OrderType.LIMIT,
|
||||
quantity: parseFloat(data.qty),
|
||||
price: parseFloat(data.price),
|
||||
status: this.mapOrderStatus(data.orderStatus),
|
||||
leverage: 1,
|
||||
orderId: data.orderId,
|
||||
filledQuantity: parseFloat(data.cumExecQty),
|
||||
fees: parseFloat(data.cumExecFee || '0'),
|
||||
demo: false,
|
||||
createdAt: new Date(parseInt(data.createdTime)),
|
||||
updatedAt: new Date(parseInt(data.updatedTime))
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== POSITION MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
* Get positions
|
||||
*/
|
||||
async getPositions(symbol?: string): Promise<Position[]> {
|
||||
const params: Record<string, unknown> = {
|
||||
category: 'linear'
|
||||
};
|
||||
|
||||
if (symbol) {
|
||||
params.symbol = symbol;
|
||||
}
|
||||
|
||||
const response = await this.authenticatedRequest<{ list: BybitPositionResponse[] }>(
|
||||
'GET',
|
||||
'/v5/position/list',
|
||||
params
|
||||
);
|
||||
|
||||
if (!response.success || !response.data?.list) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.list
|
||||
.filter(pos => parseFloat(pos.size) > 0)
|
||||
.map(data => ({
|
||||
id: `${data.symbol}-${data.side}`,
|
||||
symbol: data.symbol,
|
||||
side: data.side === 'Buy' ? 'LONG' as const : 'SHORT' as const,
|
||||
quantity: parseFloat(data.size),
|
||||
avgEntryPrice: parseFloat(data.avgPrice),
|
||||
currentPrice: parseFloat(data.avgPrice), // Would need to fetch current price separately
|
||||
marketValue: parseFloat(data.positionValue),
|
||||
unrealizedPnL: parseFloat(data.unrealisedPnl),
|
||||
unrealizedPnLPercent: (parseFloat(data.unrealisedPnl) / parseFloat(data.positionValue)) * 100,
|
||||
leverage: parseFloat(data.leverage),
|
||||
liquidationPrice: parseFloat(data.liqPrice) || undefined,
|
||||
stopLoss: parseFloat(data.stopLoss) || undefined,
|
||||
takeProfit: parseFloat(data.takeProfit) || undefined,
|
||||
openedAt: new Date(parseInt(data.createdTime)),
|
||||
demo: false
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set leverage for a symbol
|
||||
*/
|
||||
async setLeverage(symbol: string, leverage: number): Promise<boolean> {
|
||||
const response = await this.authenticatedRequest(
|
||||
'POST',
|
||||
'/v5/position/set-leverage',
|
||||
{
|
||||
category: 'linear',
|
||||
symbol,
|
||||
buyLeverage: leverage.toString(),
|
||||
sellLeverage: leverage.toString()
|
||||
}
|
||||
);
|
||||
|
||||
return response.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set trading stop (TP/SL) for a position
|
||||
*/
|
||||
async setTradingStop(params: {
|
||||
symbol: string;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
trailingStop?: number;
|
||||
}): Promise<boolean> {
|
||||
const requestParams: Record<string, unknown> = {
|
||||
category: 'linear',
|
||||
symbol: params.symbol,
|
||||
positionIdx: 0
|
||||
};
|
||||
|
||||
if (params.stopLoss) {
|
||||
requestParams.stopLoss = params.stopLoss.toString();
|
||||
}
|
||||
|
||||
if (params.takeProfit) {
|
||||
requestParams.takeProfit = params.takeProfit.toString();
|
||||
}
|
||||
|
||||
if (params.trailingStop) {
|
||||
requestParams.trailingStop = params.trailingStop.toString();
|
||||
}
|
||||
|
||||
const response = await this.authenticatedRequest(
|
||||
'POST',
|
||||
'/v5/position/trading-stop',
|
||||
requestParams
|
||||
);
|
||||
|
||||
return response.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close position
|
||||
*/
|
||||
async closePosition(symbol: string, side?: 'LONG' | 'SHORT'): Promise<Order | null> {
|
||||
const positions = await this.getPositions(symbol);
|
||||
const position = positions.find(p => !side || p.side === side);
|
||||
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.placeOrder({
|
||||
symbol,
|
||||
side: position.side === 'LONG' ? TradeAction.SELL : TradeAction.BUY,
|
||||
orderType: OrderType.MARKET,
|
||||
quantity: position.quantity
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== WALLET ====================
|
||||
|
||||
/**
|
||||
* Get wallet balance
|
||||
*/
|
||||
async getWalletBalance(accountType: 'UNIFIED' | 'CONTRACT' = 'UNIFIED'): Promise<{
|
||||
totalEquity: number;
|
||||
totalAvailableBalance: number;
|
||||
coins: Array<{ coin: string; walletBalance: number; availableToWithdraw: number }>;
|
||||
} | null> {
|
||||
const response = await this.authenticatedRequest<{
|
||||
coin: Array<{
|
||||
coin: string;
|
||||
walletBalance: string;
|
||||
availableToWithdraw: string;
|
||||
equity: string;
|
||||
}>;
|
||||
}>(
|
||||
'GET',
|
||||
'/v5/account/wallet-balance',
|
||||
{ accountType }
|
||||
);
|
||||
|
||||
if (!response.success || !response.data?.coin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coins = response.data.coin.map(c => ({
|
||||
coin: c.coin,
|
||||
walletBalance: parseFloat(c.walletBalance),
|
||||
availableToWithdraw: parseFloat(c.availableToWithdraw)
|
||||
}));
|
||||
|
||||
const totalEquity = response.data.coin.reduce(
|
||||
(sum, c) => sum + parseFloat(c.equity),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
totalEquity,
|
||||
totalAvailableBalance: totalEquity,
|
||||
coins
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== HELPERS ====================
|
||||
|
||||
private mapOrderStatus(status: string): OrderStatus {
|
||||
const statusMap: Record<string, OrderStatus> = {
|
||||
'New': OrderStatus.OPEN,
|
||||
'PartiallyFilled': OrderStatus.PARTIALLY_FILLED,
|
||||
'Filled': OrderStatus.FILLED,
|
||||
'Cancelled': OrderStatus.CANCELLED,
|
||||
'Rejected': OrderStatus.FAILED,
|
||||
'Deactivated': OrderStatus.EXPIRED
|
||||
};
|
||||
|
||||
return statusMap[status] || OrderStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance factory
|
||||
export function createBybitClient(config: TradingConfig): BybitClient {
|
||||
return new BybitClient(config);
|
||||
}
|
||||
510
src/lib/trading/core/types.ts
Normal file
510
src/lib/trading/core/types.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Core Trading Types and Interfaces for Mantle AI Trading Bot
|
||||
* Comprehensive type definitions for the entire trading system
|
||||
*/
|
||||
|
||||
// ==================== ENUMS ====================
|
||||
|
||||
export enum TradeAction {
|
||||
BUY = 'BUY',
|
||||
SELL = 'SELL',
|
||||
HOLD = 'HOLD',
|
||||
CLOSE = 'CLOSE'
|
||||
}
|
||||
|
||||
export enum OrderType {
|
||||
MARKET = 'MARKET',
|
||||
LIMIT = 'LIMIT',
|
||||
STOP_MARKET = 'STOP_MARKET',
|
||||
STOP_LIMIT = 'STOP_LIMIT'
|
||||
}
|
||||
|
||||
export enum OrderStatus {
|
||||
PENDING = 'PENDING',
|
||||
OPEN = 'OPEN',
|
||||
FILLED = 'FILLED',
|
||||
PARTIALLY_FILLED = 'PARTIALLY_FILLED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
FAILED = 'FAILED',
|
||||
EXPIRED = 'EXPIRED'
|
||||
}
|
||||
|
||||
export enum SignalStatus {
|
||||
PENDING = 'PENDING',
|
||||
ACTIVE = 'ACTIVE',
|
||||
EXECUTED = 'EXECUTED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
EXPIRED = 'EXPIRED'
|
||||
}
|
||||
|
||||
export enum SignalResult {
|
||||
WIN = 'WIN',
|
||||
LOSS = 'LOSS',
|
||||
NEUTRAL = 'NEUTRAL',
|
||||
PENDING = 'PENDING'
|
||||
}
|
||||
|
||||
export enum RiskLevel {
|
||||
CONSERVATIVE = 'CONSERVATIVE',
|
||||
MODERATE = 'MODERATE',
|
||||
AGGRESSIVE = 'AGGRESSIVE'
|
||||
}
|
||||
|
||||
export enum PositionSide {
|
||||
LONG = 'LONG',
|
||||
SHORT = 'SHORT'
|
||||
}
|
||||
|
||||
export enum TimeFrame {
|
||||
ONE_MINUTE = '1m',
|
||||
FIVE_MINUTES = '5m',
|
||||
FIFTEEN_MINUTES = '15m',
|
||||
ONE_HOUR = '1h',
|
||||
FOUR_HOURS = '4h',
|
||||
ONE_DAY = '1d',
|
||||
ONE_WEEK = '1w'
|
||||
}
|
||||
|
||||
export enum NewsSource {
|
||||
CRYPTOPANIC = 'CryptoPanic',
|
||||
COINGECKO = 'CoinGecko',
|
||||
CRYPTOCOMPARE = 'CryptoCompare',
|
||||
BINANCE_NEWS = 'BinanceNews',
|
||||
TWITTER = 'Twitter',
|
||||
REDDIT = 'Reddit',
|
||||
CUSTOM_RSS = 'CustomRSS'
|
||||
}
|
||||
|
||||
export enum SentimentLabel {
|
||||
VERY_BEARISH = 'VERY_BEARISH',
|
||||
BEARISH = 'BEARISH',
|
||||
NEUTRAL = 'NEUTRAL',
|
||||
BULLISH = 'BULLISH',
|
||||
VERY_BULLISH = 'VERY_BULLISH'
|
||||
}
|
||||
|
||||
// ==================== INTERFACES ====================
|
||||
|
||||
export interface TradingConfig {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
testnet: boolean;
|
||||
riskLevel: RiskLevel;
|
||||
maxPositionSize: number;
|
||||
maxLeverage: number;
|
||||
autoTrading: boolean;
|
||||
defaultStopLossPercent: number;
|
||||
defaultTakeProfitPercent: number;
|
||||
}
|
||||
|
||||
export interface Signal {
|
||||
id: string;
|
||||
symbol: string;
|
||||
action: TradeAction;
|
||||
confidence: number;
|
||||
rating: number;
|
||||
priceTarget?: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
reasoning: string;
|
||||
newsSources?: string[];
|
||||
sentimentScore?: number;
|
||||
technicalScore?: number;
|
||||
fundamentalScore?: number;
|
||||
status: SignalStatus;
|
||||
executedAt?: Date;
|
||||
executedPrice?: number;
|
||||
result?: SignalResult;
|
||||
resultPnL?: number;
|
||||
demo: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SignalGenerationInput {
|
||||
symbol: string;
|
||||
timeframe: TimeFrame;
|
||||
marketData: MarketDataPoint[];
|
||||
newsArticles: NewsArticle[];
|
||||
additionalContext?: string;
|
||||
}
|
||||
|
||||
export interface SignalGenerationOutput {
|
||||
signal: Omit<Signal, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
analysis: SignalAnalysis;
|
||||
riskAssessment: RiskAssessment;
|
||||
}
|
||||
|
||||
export interface SignalAnalysis {
|
||||
technicalAnalysis: TechnicalAnalysis;
|
||||
fundamentalAnalysis: FundamentalAnalysis;
|
||||
sentimentAnalysis: SentimentAnalysis;
|
||||
overallScore: number;
|
||||
keyFactors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface TechnicalAnalysis {
|
||||
trend: 'BULLISH' | 'BEARISH' | 'SIDEWAYS';
|
||||
trendStrength: number;
|
||||
supportLevels: number[];
|
||||
resistanceLevels: number[];
|
||||
indicators: Record<string, number>;
|
||||
patterns: string[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface FundamentalAnalysis {
|
||||
newsImpact: number;
|
||||
marketEvents: string[];
|
||||
economicFactors: string[];
|
||||
tokenomics?: TokenomicsData;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface TokenomicsData {
|
||||
circulatingSupply?: number;
|
||||
totalSupply?: number;
|
||||
marketCap?: number;
|
||||
volume24h?: number;
|
||||
priceChange24h?: number;
|
||||
priceChange7d?: number;
|
||||
}
|
||||
|
||||
export interface SentimentAnalysis {
|
||||
overallSentiment: number;
|
||||
sentimentLabel: SentimentLabel;
|
||||
newsSentiment: number;
|
||||
socialSentiment: number;
|
||||
fearGreedIndex?: number;
|
||||
keyTopics: string[];
|
||||
trendingKeywords: string[];
|
||||
}
|
||||
|
||||
export interface RiskAssessment {
|
||||
riskScore: number;
|
||||
riskLevel: RiskLevel;
|
||||
maxRecommendedPosition: number;
|
||||
suggestedStopLoss: number;
|
||||
suggestedTakeProfit: number;
|
||||
riskFactors: string[];
|
||||
marketVolatility: number;
|
||||
liquidityRisk: number;
|
||||
}
|
||||
|
||||
export interface MarketDataPoint {
|
||||
symbol: string;
|
||||
timeframe: TimeFrame;
|
||||
timestamp: Date;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface OrderBook {
|
||||
symbol: string;
|
||||
bids: OrderBookLevel[];
|
||||
asks: OrderBookLevel[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface OrderBookLevel {
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Ticker {
|
||||
symbol: string;
|
||||
lastPrice: number;
|
||||
bidPrice: number;
|
||||
askPrice: number;
|
||||
bidQty: number;
|
||||
askQty: number;
|
||||
volume24h: number;
|
||||
priceChange24h: number;
|
||||
priceChangePercent24h: number;
|
||||
highPrice24h: number;
|
||||
lowPrice24h: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: TradeAction;
|
||||
type: OrderType;
|
||||
quantity: number;
|
||||
price?: number;
|
||||
stopPrice?: number;
|
||||
status: OrderStatus;
|
||||
leverage: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
orderId?: string;
|
||||
executedAt?: Date;
|
||||
executedPrice?: number;
|
||||
filledQuantity: number;
|
||||
fees: number;
|
||||
demo: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: PositionSide;
|
||||
quantity: number;
|
||||
avgEntryPrice: number;
|
||||
currentPrice: number;
|
||||
marketValue: number;
|
||||
unrealizedPnL: number;
|
||||
unrealizedPnLPercent: number;
|
||||
leverage: number;
|
||||
liquidationPrice?: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
openedAt: Date;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
id: string;
|
||||
name: string;
|
||||
totalValue: number;
|
||||
cashBalance: number;
|
||||
realizedPnL: number;
|
||||
unrealizedPnL: number;
|
||||
positions: Position[];
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
signalId?: string;
|
||||
symbol: string;
|
||||
side: TradeAction;
|
||||
orderType: OrderType;
|
||||
quantity: number;
|
||||
price: number;
|
||||
leverage: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
status: OrderStatus;
|
||||
orderId?: string;
|
||||
executedAt?: Date;
|
||||
closedAt?: Date;
|
||||
pnl?: number;
|
||||
fees?: number;
|
||||
demo: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NewsArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
source: string;
|
||||
sourceUrl?: string;
|
||||
author?: string;
|
||||
category?: string;
|
||||
sentiment?: number;
|
||||
importance?: number;
|
||||
tags?: string[];
|
||||
publishedAt?: Date;
|
||||
fetchedAt: Date;
|
||||
processed: boolean;
|
||||
vectorId?: string;
|
||||
}
|
||||
|
||||
export interface NewsQuery {
|
||||
sources?: NewsSource[];
|
||||
categories?: string[];
|
||||
symbols?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit?: number;
|
||||
minImportance?: number;
|
||||
}
|
||||
|
||||
export interface BacktestConfig {
|
||||
name: string;
|
||||
symbol: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
initialCapital: number;
|
||||
strategy: string;
|
||||
parameters: Record<string, unknown>;
|
||||
fees: number; // Fee percentage per trade
|
||||
slippage: number; // Slippage percentage
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
signalId?: string;
|
||||
symbol: string;
|
||||
action: TradeAction;
|
||||
entryPrice: number;
|
||||
exitPrice?: number;
|
||||
quantity: number;
|
||||
pnl?: number;
|
||||
pnlPercent?: number;
|
||||
executedAt: Date;
|
||||
closedAt?: Date;
|
||||
}
|
||||
|
||||
export interface BacktestSession {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
initialCapital: number;
|
||||
finalCapital?: number;
|
||||
totalTrades: number;
|
||||
winRate?: number;
|
||||
maxDrawdown?: number;
|
||||
sharpeRatio?: number;
|
||||
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED';
|
||||
results: BacktestResult[];
|
||||
}
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
totalReturn: number;
|
||||
annualizedReturn: number;
|
||||
sharpeRatio: number;
|
||||
sortinoRatio: number;
|
||||
maxDrawdown: number;
|
||||
winRate: number;
|
||||
profitFactor: number;
|
||||
averageWin: number;
|
||||
averageLoss: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
}
|
||||
|
||||
// ==================== API RESPONSE TYPES ====================
|
||||
|
||||
export interface BybitTickerResponse {
|
||||
symbol: string;
|
||||
lastPrice: string;
|
||||
bid1Price: string;
|
||||
ask1Price: string;
|
||||
bid1Size: string;
|
||||
ask1Size: string;
|
||||
volume24h: string;
|
||||
price24hPcnt: string;
|
||||
highPrice24h: string;
|
||||
lowPrice24h: string;
|
||||
}
|
||||
|
||||
export interface BybitKlineResponse {
|
||||
startTime: number;
|
||||
openPrice: string;
|
||||
highPrice: string;
|
||||
lowPrice: string;
|
||||
closePrice: string;
|
||||
volume: string;
|
||||
turnover: string;
|
||||
}
|
||||
|
||||
export interface BybitOrderResponse {
|
||||
orderId: string;
|
||||
orderLinkId: string;
|
||||
symbol: string;
|
||||
side: string;
|
||||
orderType: string;
|
||||
price: string;
|
||||
qty: string;
|
||||
orderStatus: string;
|
||||
cumExecQty: string;
|
||||
cumExecFee: string;
|
||||
createdTime: string;
|
||||
updatedTime: string;
|
||||
}
|
||||
|
||||
export interface BybitPositionResponse {
|
||||
symbol: string;
|
||||
side: string;
|
||||
size: string;
|
||||
avgPrice: string;
|
||||
positionValue: string;
|
||||
unrealisedPnl: string;
|
||||
leverage: string;
|
||||
liqPrice: string;
|
||||
stopLoss: string;
|
||||
takeProfit: string;
|
||||
createdTime: string;
|
||||
updatedTime: string;
|
||||
}
|
||||
|
||||
// ==================== WEBSOCKET TYPES ====================
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: string;
|
||||
data: T;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface WSTickerMessage {
|
||||
symbol: string;
|
||||
ticker: Ticker;
|
||||
}
|
||||
|
||||
export interface WSSignalMessage {
|
||||
signal: Signal;
|
||||
analysis: SignalAnalysis;
|
||||
}
|
||||
|
||||
export interface WSOrderMessage {
|
||||
order: Order;
|
||||
status: OrderStatus;
|
||||
}
|
||||
|
||||
export interface WSPortfolioMessage {
|
||||
portfolio: Portfolio;
|
||||
changes: Partial<Portfolio>;
|
||||
}
|
||||
|
||||
export interface WSNotificationMessage {
|
||||
level: 'INFO' | 'WARNING' | 'ERROR' | 'SUCCESS';
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ==================== UTILITY TYPES ====================
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface OHLCV {
|
||||
timestamp: Date;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
552
src/lib/trading/demo/demo-trader.ts
Normal file
552
src/lib/trading/demo/demo-trader.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Demo Trading Mode for Mantle AI Trading Bot
|
||||
* Paper trading system for testing signals without real money
|
||||
*/
|
||||
|
||||
import {
|
||||
Signal,
|
||||
Position,
|
||||
Order,
|
||||
Portfolio,
|
||||
TradeAction,
|
||||
OrderType,
|
||||
OrderStatus,
|
||||
Ticker
|
||||
} from '../core/types';
|
||||
|
||||
export interface DemoOrder {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: TradeAction;
|
||||
type: OrderType;
|
||||
quantity: number;
|
||||
price: number;
|
||||
leverage: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
status: OrderStatus;
|
||||
filledAt?: Date;
|
||||
closedAt?: Date;
|
||||
pnl?: number;
|
||||
signalId?: string;
|
||||
}
|
||||
|
||||
export interface DemoPosition {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'LONG' | 'SHORT';
|
||||
quantity: number;
|
||||
avgEntryPrice: number;
|
||||
currentPrice: number;
|
||||
marketValue: number;
|
||||
unrealizedPnL: number;
|
||||
unrealizedPnLPercent: number;
|
||||
leverage: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
openedAt: Date;
|
||||
signalId?: string;
|
||||
}
|
||||
|
||||
export class DemoTrader {
|
||||
private portfolio: Portfolio;
|
||||
private positions: Map<string, DemoPosition> = new Map();
|
||||
private orders: DemoOrder[] = [];
|
||||
private tradeHistory: DemoOrder[] = [];
|
||||
private currentPrices: Map<string, number> = new Map();
|
||||
private listeners: Set<(event: string, data: unknown) => void> = new Set();
|
||||
|
||||
constructor(initialCapital: number = 10000) {
|
||||
this.portfolio = {
|
||||
id: 'demo-portfolio',
|
||||
name: 'Demo Portfolio',
|
||||
totalValue: initialCapital,
|
||||
cashBalance: initialCapital,
|
||||
realizedPnL: 0,
|
||||
unrealizedPnL: 0,
|
||||
positions: [],
|
||||
demo: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
subscribe(callback: (event: string, data: unknown) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event
|
||||
*/
|
||||
private emit(event: string, data: unknown): void {
|
||||
this.listeners.forEach(cb => cb(event, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current portfolio state
|
||||
*/
|
||||
getPortfolio(): Portfolio {
|
||||
this.updatePortfolioValue();
|
||||
return { ...this.portfolio };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all positions
|
||||
*/
|
||||
getPositions(): DemoPosition[] {
|
||||
return Array.from(this.positions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open orders
|
||||
*/
|
||||
getOpenOrders(): DemoOrder[] {
|
||||
return this.orders.filter(o => o.status === OrderStatus.OPEN || o.status === OrderStatus.PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trade history
|
||||
*/
|
||||
getTradeHistory(): DemoOrder[] {
|
||||
return [...this.tradeHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current price for a symbol
|
||||
*/
|
||||
updatePrice(symbol: string, price: number): void {
|
||||
this.currentPrices.set(symbol, price);
|
||||
|
||||
// Update position values
|
||||
const position = this.positions.get(symbol);
|
||||
if (position) {
|
||||
position.currentPrice = price;
|
||||
position.marketValue = position.quantity * price;
|
||||
|
||||
if (position.side === 'LONG') {
|
||||
position.unrealizedPnL = (price - position.avgEntryPrice) * position.quantity;
|
||||
} else {
|
||||
position.unrealizedPnL = (position.avgEntryPrice - price) * position.quantity;
|
||||
}
|
||||
|
||||
position.unrealizedPnLPercent = (position.unrealizedPnL / (position.avgEntryPrice * position.quantity)) * 100;
|
||||
|
||||
// Check stop loss and take profit
|
||||
this.checkStopLevels(position);
|
||||
}
|
||||
|
||||
// Check pending orders
|
||||
this.checkPendingOrders(symbol, price);
|
||||
|
||||
this.emit('price_update', { symbol, price });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple prices at once
|
||||
*/
|
||||
updatePrices(tickers: Ticker[]): void {
|
||||
tickers.forEach(ticker => {
|
||||
this.updatePrice(ticker.symbol, ticker.lastPrice);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a demo order
|
||||
*/
|
||||
placeOrder(params: {
|
||||
symbol: string;
|
||||
side: TradeAction;
|
||||
type: OrderType;
|
||||
quantity: number;
|
||||
price?: number;
|
||||
leverage?: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
signalId?: string;
|
||||
}): DemoOrder {
|
||||
const currentPrice = this.currentPrices.get(params.symbol) || 0;
|
||||
const executionPrice = params.type === OrderType.LIMIT && params.price
|
||||
? params.price
|
||||
: currentPrice;
|
||||
|
||||
// Check if we have enough capital
|
||||
const requiredCapital = executionPrice * params.quantity * (params.leverage || 1);
|
||||
if (params.side === TradeAction.BUY && requiredCapital > this.portfolio.cashBalance) {
|
||||
throw new Error('Insufficient capital for this order');
|
||||
}
|
||||
|
||||
const order: DemoOrder = {
|
||||
id: `demo-order-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
symbol: params.symbol,
|
||||
side: params.side,
|
||||
type: params.type,
|
||||
quantity: params.quantity,
|
||||
price: executionPrice,
|
||||
leverage: params.leverage || 1,
|
||||
stopLoss: params.stopLoss,
|
||||
takeProfit: params.takeProfit,
|
||||
status: params.type === OrderType.MARKET ? OrderStatus.FILLED : OrderStatus.PENDING,
|
||||
signalId: params.signalId
|
||||
};
|
||||
|
||||
// Execute market orders immediately
|
||||
if (params.type === OrderType.MARKET) {
|
||||
this.executeOrder(order);
|
||||
} else {
|
||||
this.orders.push(order);
|
||||
}
|
||||
|
||||
this.emit('order_placed', order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a signal
|
||||
*/
|
||||
async executeSignal(signal: Signal): Promise<DemoOrder | null> {
|
||||
if (signal.action === TradeAction.HOLD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Calculate position size based on confidence
|
||||
const baseRisk = 0.02; // 2% risk per trade
|
||||
const riskMultiplier = signal.confidence * 2; // Scale with confidence
|
||||
const riskAmount = this.portfolio.totalValue * baseRisk * riskMultiplier;
|
||||
|
||||
const currentPrice = this.currentPrices.get(signal.symbol) || signal.priceTarget || 0;
|
||||
if (currentPrice === 0) {
|
||||
throw new Error('No price available for this symbol');
|
||||
}
|
||||
|
||||
const quantity = riskAmount / currentPrice;
|
||||
|
||||
return this.placeOrder({
|
||||
symbol: signal.symbol,
|
||||
side: signal.action,
|
||||
type: OrderType.MARKET,
|
||||
quantity: Math.floor(quantity * 100000000) / 100000000, // Round to 8 decimals
|
||||
stopLoss: signal.stopLoss,
|
||||
takeProfit: signal.takeProfit,
|
||||
signalId: signal.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error executing signal:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a position
|
||||
*/
|
||||
closePosition(symbol: string, quantity?: number): DemoOrder | null {
|
||||
const position = this.positions.get(symbol);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeQuantity = quantity || position.quantity;
|
||||
const closeSide = position.side === 'LONG' ? TradeAction.SELL : TradeAction.BUY;
|
||||
|
||||
const order = this.placeOrder({
|
||||
symbol,
|
||||
side: closeSide,
|
||||
type: OrderType.MARKET,
|
||||
quantity: closeQuantity
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order
|
||||
*/
|
||||
cancelOrder(orderId: string): boolean {
|
||||
const orderIndex = this.orders.findIndex(o => o.id === orderId);
|
||||
if (orderIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const order = this.orders[orderIndex];
|
||||
if (order.status !== OrderStatus.PENDING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
order.status = OrderStatus.CANCELLED;
|
||||
this.orders.splice(orderIndex, 1);
|
||||
this.tradeHistory.push(order);
|
||||
|
||||
this.emit('order_cancelled', order);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an order
|
||||
*/
|
||||
private executeOrder(order: DemoOrder): void {
|
||||
const existingPosition = this.positions.get(order.symbol);
|
||||
|
||||
if (order.side === TradeAction.BUY) {
|
||||
if (existingPosition && existingPosition.side === 'LONG') {
|
||||
// Add to existing long position
|
||||
const newQuantity = existingPosition.quantity + order.quantity;
|
||||
const newAvgPrice = (
|
||||
(existingPosition.avgEntryPrice * existingPosition.quantity) +
|
||||
(order.price * order.quantity)
|
||||
) / newQuantity;
|
||||
|
||||
existingPosition.quantity = newQuantity;
|
||||
existingPosition.avgEntryPrice = newAvgPrice;
|
||||
existingPosition.marketValue = newQuantity * order.price;
|
||||
} else if (existingPosition && existingPosition.side === 'SHORT') {
|
||||
// Close short position
|
||||
this.closeExistingPosition(existingPosition, order);
|
||||
} else {
|
||||
// Create new long position
|
||||
const newPosition: DemoPosition = {
|
||||
id: `pos-${order.symbol}`,
|
||||
symbol: order.symbol,
|
||||
side: 'LONG',
|
||||
quantity: order.quantity,
|
||||
avgEntryPrice: order.price,
|
||||
currentPrice: order.price,
|
||||
marketValue: order.quantity * order.price,
|
||||
unrealizedPnL: 0,
|
||||
unrealizedPnLPercent: 0,
|
||||
leverage: order.leverage,
|
||||
stopLoss: order.stopLoss,
|
||||
takeProfit: order.takeProfit,
|
||||
openedAt: new Date(),
|
||||
signalId: order.signalId
|
||||
};
|
||||
this.positions.set(order.symbol, newPosition);
|
||||
}
|
||||
|
||||
// Deduct from cash
|
||||
this.portfolio.cashBalance -= order.price * order.quantity;
|
||||
} else if (order.side === TradeAction.SELL) {
|
||||
if (existingPosition && existingPosition.side === 'SHORT') {
|
||||
// Add to existing short position
|
||||
const newQuantity = existingPosition.quantity + order.quantity;
|
||||
const newAvgPrice = (
|
||||
(existingPosition.avgEntryPrice * existingPosition.quantity) +
|
||||
(order.price * order.quantity)
|
||||
) / newQuantity;
|
||||
|
||||
existingPosition.quantity = newQuantity;
|
||||
existingPosition.avgEntryPrice = newAvgPrice;
|
||||
} else if (existingPosition && existingPosition.side === 'LONG') {
|
||||
// Close long position
|
||||
this.closeExistingPosition(existingPosition, order);
|
||||
} else {
|
||||
// Create new short position
|
||||
const newPosition: DemoPosition = {
|
||||
id: `pos-${order.symbol}`,
|
||||
symbol: order.symbol,
|
||||
side: 'SHORT',
|
||||
quantity: order.quantity,
|
||||
avgEntryPrice: order.price,
|
||||
currentPrice: order.price,
|
||||
marketValue: order.quantity * order.price,
|
||||
unrealizedPnL: 0,
|
||||
unrealizedPnLPercent: 0,
|
||||
leverage: order.leverage,
|
||||
stopLoss: order.stopLoss,
|
||||
takeProfit: order.takeProfit,
|
||||
openedAt: new Date(),
|
||||
signalId: order.signalId
|
||||
};
|
||||
this.positions.set(order.symbol, newPosition);
|
||||
}
|
||||
|
||||
// Add to cash (for shorts, we receive cash)
|
||||
this.portfolio.cashBalance += order.price * order.quantity;
|
||||
}
|
||||
|
||||
order.status = OrderStatus.FILLED;
|
||||
order.filledAt = new Date();
|
||||
this.tradeHistory.push(order);
|
||||
|
||||
this.emit('order_filled', order);
|
||||
this.emit('position_updated', this.positions.get(order.symbol));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close existing position
|
||||
*/
|
||||
private closeExistingPosition(position: DemoPosition, order: DemoOrder): void {
|
||||
const closeQuantity = Math.min(position.quantity, order.quantity);
|
||||
|
||||
// Calculate realized PnL
|
||||
let pnl: number;
|
||||
if (position.side === 'LONG') {
|
||||
pnl = (order.price - position.avgEntryPrice) * closeQuantity;
|
||||
} else {
|
||||
pnl = (position.avgEntryPrice - order.price) * closeQuantity;
|
||||
}
|
||||
|
||||
// Update order PnL
|
||||
order.pnl = pnl;
|
||||
order.closedAt = new Date();
|
||||
|
||||
// Update portfolio
|
||||
this.portfolio.realizedPnL += pnl;
|
||||
if (position.side === 'LONG') {
|
||||
this.portfolio.cashBalance += order.price * closeQuantity;
|
||||
}
|
||||
|
||||
// Update or remove position
|
||||
if (closeQuantity >= position.quantity) {
|
||||
this.positions.delete(position.symbol);
|
||||
this.emit('position_closed', position);
|
||||
} else {
|
||||
position.quantity -= closeQuantity;
|
||||
position.marketValue = position.quantity * order.price;
|
||||
}
|
||||
|
||||
// Update signal result if applicable
|
||||
if (position.signalId) {
|
||||
this.emit('signal_result', {
|
||||
signalId: position.signalId,
|
||||
pnl,
|
||||
result: pnl > 0 ? 'WIN' : pnl < 0 ? 'LOSS' : 'NEUTRAL'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stop loss and take profit levels
|
||||
*/
|
||||
private checkStopLevels(position: DemoPosition): void {
|
||||
if (position.stopLoss && position.currentPrice <= position.stopLoss && position.side === 'LONG') {
|
||||
this.closePosition(position.symbol);
|
||||
this.emit('stop_loss_triggered', position);
|
||||
}
|
||||
|
||||
if (position.stopLoss && position.currentPrice >= position.stopLoss && position.side === 'SHORT') {
|
||||
this.closePosition(position.symbol);
|
||||
this.emit('stop_loss_triggered', position);
|
||||
}
|
||||
|
||||
if (position.takeProfit && position.currentPrice >= position.takeProfit && position.side === 'LONG') {
|
||||
this.closePosition(position.symbol);
|
||||
this.emit('take_profit_triggered', position);
|
||||
}
|
||||
|
||||
if (position.takeProfit && position.currentPrice <= position.takeProfit && position.side === 'SHORT') {
|
||||
this.closePosition(position.symbol);
|
||||
this.emit('take_profit_triggered', position);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pending limit orders
|
||||
*/
|
||||
private checkPendingOrders(symbol: string, price: number): void {
|
||||
this.orders.forEach(order => {
|
||||
if (order.symbol !== symbol || order.status !== OrderStatus.PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.type === OrderType.LIMIT) {
|
||||
// For buy limit, execute when price drops to or below limit price
|
||||
if (order.side === TradeAction.BUY && price <= order.price) {
|
||||
this.executeOrder(order);
|
||||
}
|
||||
// For sell limit, execute when price rises to or above limit price
|
||||
if (order.side === TradeAction.SELL && price >= order.price) {
|
||||
this.executeOrder(order);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update portfolio value
|
||||
*/
|
||||
private updatePortfolioValue(): void {
|
||||
let totalUnrealizedPnL = 0;
|
||||
|
||||
this.positions.forEach(position => {
|
||||
totalUnrealizedPnL += position.unrealizedPnL;
|
||||
});
|
||||
|
||||
this.portfolio.unrealizedPnL = totalUnrealizedPnL;
|
||||
this.portfolio.totalValue = this.portfolio.cashBalance + totalUnrealizedPnL;
|
||||
this.portfolio.positions = Array.from(this.positions.values()).map(p => ({
|
||||
id: p.id,
|
||||
symbol: p.symbol,
|
||||
side: p.side,
|
||||
quantity: p.quantity,
|
||||
avgEntryPrice: p.avgEntryPrice,
|
||||
currentPrice: p.currentPrice,
|
||||
marketValue: p.marketValue,
|
||||
unrealizedPnL: p.unrealizedPnL,
|
||||
unrealizedPnLPercent: p.unrealizedPnLPercent,
|
||||
leverage: p.leverage,
|
||||
openedAt: p.openedAt,
|
||||
demo: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset demo account
|
||||
*/
|
||||
reset(initialCapital: number = 10000): void {
|
||||
this.positions.clear();
|
||||
this.orders = [];
|
||||
this.tradeHistory = [];
|
||||
this.portfolio = {
|
||||
id: 'demo-portfolio',
|
||||
name: 'Demo Portfolio',
|
||||
totalValue: initialCapital,
|
||||
cashBalance: initialCapital,
|
||||
realizedPnL: 0,
|
||||
unrealizedPnL: 0,
|
||||
positions: [],
|
||||
demo: true
|
||||
};
|
||||
|
||||
this.emit('portfolio_reset', this.portfolio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics
|
||||
*/
|
||||
getStatistics(): {
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
winRate: number;
|
||||
totalPnL: number;
|
||||
averagePnL: number;
|
||||
bestTrade: number;
|
||||
worstTrade: number;
|
||||
profitFactor: number;
|
||||
} {
|
||||
const trades = this.tradeHistory.filter(t => t.pnl !== undefined);
|
||||
const wins = trades.filter(t => (t.pnl || 0) > 0);
|
||||
const losses = trades.filter(t => (t.pnl || 0) <= 0);
|
||||
|
||||
const totalPnL = trades.reduce((sum, t) => sum + (t.pnl || 0), 0);
|
||||
const grossProfit = wins.reduce((sum, t) => sum + (t.pnl || 0), 0);
|
||||
const grossLoss = Math.abs(losses.reduce((sum, t) => sum + (t.pnl || 0), 0));
|
||||
|
||||
return {
|
||||
totalTrades: trades.length,
|
||||
winningTrades: wins.length,
|
||||
losingTrades: losses.length,
|
||||
winRate: trades.length > 0 ? (wins.length / trades.length) * 100 : 0,
|
||||
totalPnL,
|
||||
averagePnL: trades.length > 0 ? totalPnL / trades.length : 0,
|
||||
bestTrade: trades.length > 0 ? Math.max(...trades.map(t => t.pnl || 0)) : 0,
|
||||
worstTrade: trades.length > 0 ? Math.min(...trades.map(t => t.pnl || 0)) : 0,
|
||||
profitFactor: grossLoss > 0 ? grossProfit / grossLoss : 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const demoTrader = new DemoTrader();
|
||||
561
src/lib/trading/news/news-aggregator.ts
Normal file
561
src/lib/trading/news/news-aggregator.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* News Aggregation Service for Mantle AI Trading Bot
|
||||
* Aggregates news from multiple sources for fundamental analysis
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import {
|
||||
NewsArticle,
|
||||
NewsSource,
|
||||
NewsQuery,
|
||||
SentimentLabel,
|
||||
APIResponse
|
||||
} from '../core/types';
|
||||
|
||||
// News API configurations
|
||||
const NEWS_APIS = {
|
||||
cryptopanic: {
|
||||
baseUrl: 'https://cryptopanic.com/api/v1',
|
||||
requiresAuth: true
|
||||
},
|
||||
coingecko: {
|
||||
baseUrl: 'https://api.coingecko.com/api/v3',
|
||||
requiresAuth: false
|
||||
},
|
||||
cryptocompare: {
|
||||
baseUrl: 'https://min-api.cryptocompare.com/data/v2',
|
||||
requiresAuth: true
|
||||
}
|
||||
};
|
||||
|
||||
// Sentiment keywords for analysis
|
||||
const SENTIMENT_KEYWORDS = {
|
||||
bullish: [
|
||||
'bullish', 'surge', 'rally', 'breakout', 'gain', 'rise', 'soar', 'pump',
|
||||
'positive', 'growth', 'adoption', 'partnership', 'launch', 'upgrade',
|
||||
'milestone', 'achievement', 'success', 'profit', 'bull run', 'moon',
|
||||
'institutional', 'investment', 'buy', 'accumulate', 'support', 'hold'
|
||||
],
|
||||
bearish: [
|
||||
'bearish', 'crash', 'dump', 'decline', 'fall', 'drop', 'sell-off',
|
||||
'negative', 'loss', 'risk', 'warning', 'concern', 'hack', 'exploit',
|
||||
'regulation', 'ban', 'restrict', 'fraud', 'scam', 'bear market',
|
||||
'liquidation', 'bankruptcy', 'investigation', 'lawsuit', 'fine'
|
||||
]
|
||||
};
|
||||
|
||||
// Category mappings
|
||||
const CATEGORIES: Record<string, string[]> = {
|
||||
'BTC': ['bitcoin', 'btc', 'satoshi', 'lightning network'],
|
||||
'ETH': ['ethereum', 'eth', 'vitalik', 'smart contract', 'defi', 'nft'],
|
||||
'DeFi': ['defi', 'yield', 'liquidity', 'staking', 'amm', 'dex'],
|
||||
'NFT': ['nft', 'opensea', 'collectible', 'digital art'],
|
||||
'Regulation': ['sec', 'regulation', 'law', 'compliance', 'government', 'ban'],
|
||||
'Exchange': ['exchange', 'binance', 'coinbase', 'kraken', 'ftx'],
|
||||
'Adoption': ['adoption', 'institutional', 'payment', 'merchant', 'country']
|
||||
};
|
||||
|
||||
export class NewsAggregator {
|
||||
private cryptopanicApiKey?: string;
|
||||
private cryptocompareApiKey?: string;
|
||||
private cache: Map<string, { data: NewsArticle[]; timestamp: number }>;
|
||||
private cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(config?: {
|
||||
cryptopanicApiKey?: string;
|
||||
cryptocompareApiKey?: string;
|
||||
}) {
|
||||
this.cryptopanicApiKey = config?.cryptopanicApiKey;
|
||||
this.cryptocompareApiKey = config?.cryptocompareApiKey;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch news from all configured sources
|
||||
*/
|
||||
async fetchAllNews(query: NewsQuery = {}): Promise<NewsArticle[]> {
|
||||
const cacheKey = JSON.stringify(query);
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const articles: NewsArticle[] = [];
|
||||
const sources = query.sources || Object.values(NewsSource);
|
||||
|
||||
// Fetch from all sources in parallel
|
||||
const fetchPromises = sources.map(source => this.fetchFromSource(source, query));
|
||||
const results = await Promise.allSettled(fetchPromises);
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
articles.push(...result.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate by URL
|
||||
const uniqueArticles = this.deduplicateArticles(articles);
|
||||
|
||||
// Sort by published date
|
||||
uniqueArticles.sort((a, b) => {
|
||||
const dateA = a.publishedAt?.getTime() || 0;
|
||||
const dateB = b.publishedAt?.getTime() || 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Apply limit
|
||||
const limited = query.limit ? uniqueArticles.slice(0, query.limit) : uniqueArticles;
|
||||
|
||||
// Cache results
|
||||
this.cache.set(cacheKey, { data: limited, timestamp: Date.now() });
|
||||
|
||||
return limited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch news from a specific source
|
||||
*/
|
||||
private async fetchFromSource(
|
||||
source: NewsSource,
|
||||
query: NewsQuery
|
||||
): Promise<NewsArticle[]> {
|
||||
switch (source) {
|
||||
case NewsSource.CRYPTOPANIC:
|
||||
return this.fetchCryptoPanic(query);
|
||||
case NewsSource.COINGECKO:
|
||||
return this.fetchCoinGecko(query);
|
||||
case NewsSource.CRYPTOCOMPARE:
|
||||
return this.fetchCryptoCompare(query);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from CryptoPanic API
|
||||
*/
|
||||
private async fetchCryptoPanic(query: NewsQuery): Promise<NewsArticle[]> {
|
||||
if (!this.cryptopanicApiKey) {
|
||||
console.warn('CryptoPanic API key not configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
auth_token: this.cryptopanicApiKey,
|
||||
public: 'true'
|
||||
};
|
||||
|
||||
if (query.symbols?.length) {
|
||||
params.currencies = query.symbols.join(',');
|
||||
}
|
||||
|
||||
const response = await axios.get(`${NEWS_APIS.cryptopanic.baseUrl}/posts/`, {
|
||||
params,
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
if (!response.data?.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.results.map((post: Record<string, unknown>): NewsArticle => ({
|
||||
id: `cp-${post.id}`,
|
||||
title: post.title as string,
|
||||
content: post.body as string || undefined,
|
||||
source: NewsSource.CRYPTOPANIC,
|
||||
sourceUrl: post.url as string,
|
||||
author: post.source?.domain as string || undefined,
|
||||
category: this.categorizeArticle(post.title as string),
|
||||
sentiment: this.analyzeSentiment(`${post.title} ${post.body || ''}`),
|
||||
importance: this.calculateImportance(post),
|
||||
tags: this.extractTags(`${post.title} ${post.body || ''}`),
|
||||
publishedAt: post.published_at ? new Date(post.published_at as string) : undefined,
|
||||
fetchedAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('CryptoPanic fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from CoinGecko API (status updates)
|
||||
*/
|
||||
private async fetchCoinGecko(query: NewsQuery): Promise<NewsArticle[]> {
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
per_page: query.limit || 25,
|
||||
page: 1
|
||||
};
|
||||
|
||||
// CoinGecko status updates endpoint
|
||||
const response = await axios.get(
|
||||
`${NEWS_APIS.coingecko.baseUrl}/status_updates`,
|
||||
{ params, timeout: 10000 }
|
||||
);
|
||||
|
||||
if (!response.data?.status_updates) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.status_updates.map((update: Record<string, unknown>): NewsArticle => ({
|
||||
id: `cg-${update.id}`,
|
||||
title: (update.project?.name as string) + ' Status Update',
|
||||
content: update.description as string,
|
||||
source: NewsSource.COINGECKO,
|
||||
sourceUrl: `https://www.coingecko.com/en/coins/${update.project?.id}`,
|
||||
author: update.project?.name as string || 'CoinGecko',
|
||||
category: 'Project Updates',
|
||||
sentiment: this.analyzeSentiment(update.description as string),
|
||||
importance: 0.6,
|
||||
tags: [update.project?.symbol as string, 'status-update'],
|
||||
publishedAt: new Date(update.created_at as string),
|
||||
fetchedAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
} catch (error) {
|
||||
// CoinGecko might have changed their API, return empty array
|
||||
console.error('CoinGecko fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from CryptoCompare API
|
||||
*/
|
||||
private async fetchCryptoCompare(query: NewsQuery): Promise<NewsArticle[]> {
|
||||
if (!this.cryptocompareApiKey) {
|
||||
// Try without API key (limited rate)
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${NEWS_APIS.cryptocompare.baseUrl}/news/?lang=EN`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
if (!response.data?.Data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.Data.map((article: Record<string, unknown>): NewsArticle => ({
|
||||
id: `cc-${article.id}`,
|
||||
title: article.title as string,
|
||||
content: article.body as string,
|
||||
source: NewsSource.CRYPTOCOMPARE,
|
||||
sourceUrl: article.url as string,
|
||||
author: article.source as string || 'CryptoCompare',
|
||||
category: article.category as string || 'General',
|
||||
sentiment: this.analyzeSentiment(`${article.title} ${article.body}`),
|
||||
importance: this.calculateCryptoCompareImportance(article),
|
||||
tags: (article.categories as string || '').split('|').filter(Boolean),
|
||||
publishedAt: new Date(article.published_on as number * 1000),
|
||||
fetchedAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('CryptoCompare fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${NEWS_APIS.cryptocompare.baseUrl}/news/?lang=EN&api_key=${this.cryptocompareApiKey}`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
if (!response.data?.Data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let articles = response.data.Data.map((article: Record<string, unknown>): NewsArticle => ({
|
||||
id: `cc-${article.id}`,
|
||||
title: article.title as string,
|
||||
content: article.body as string,
|
||||
source: NewsSource.CRYPTOCOMPARE,
|
||||
sourceUrl: article.url as string,
|
||||
author: article.source as string || 'CryptoCompare',
|
||||
category: article.category as string || 'General',
|
||||
sentiment: this.analyzeSentiment(`${article.title} ${article.body}`),
|
||||
importance: this.calculateCryptoCompareImportance(article),
|
||||
tags: (article.categories as string || '').split('|').filter(Boolean),
|
||||
publishedAt: new Date(article.published_on as number * 1000),
|
||||
fetchedAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
|
||||
// Filter by symbols if specified
|
||||
if (query.symbols?.length) {
|
||||
const symbolsLower = query.symbols.map(s => s.toLowerCase());
|
||||
articles = articles.filter(a =>
|
||||
symbolsLower.some(s =>
|
||||
a.title.toLowerCase().includes(s) ||
|
||||
a.tags?.some(t => t.toLowerCase().includes(s))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return articles;
|
||||
} catch (error) {
|
||||
console.error('CryptoCompare fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch news from custom RSS feeds
|
||||
*/
|
||||
async fetchFromRSS(feedUrl: string): Promise<NewsArticle[]> {
|
||||
try {
|
||||
// Use a simple RSS parser approach
|
||||
const response = await axios.get(feedUrl, {
|
||||
timeout: 10000,
|
||||
responseType: 'text'
|
||||
});
|
||||
|
||||
// Parse RSS XML (simplified - in production use a proper RSS parser)
|
||||
const articles: NewsArticle[] = [];
|
||||
const itemMatches = response.data.match(/<item>([\s\S]*?)<\/item>/g) || [];
|
||||
|
||||
itemMatches.forEach((item: string, index: number) => {
|
||||
const titleMatch = item.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>|<title>(.*?)<\/title>/);
|
||||
const linkMatch = item.match(/<link>(.*?)<\/link>/);
|
||||
const descMatch = item.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>|<description>(.*?)<\/description>/);
|
||||
const dateMatch = item.match(/<pubDate>(.*?)<\/pubDate>/);
|
||||
|
||||
if (titleMatch) {
|
||||
const title = titleMatch[1] || titleMatch[2];
|
||||
articles.push({
|
||||
id: `rss-${Date.now()}-${index}`,
|
||||
title,
|
||||
content: descMatch?.[1] || descMatch?.[2] || undefined,
|
||||
source: NewsSource.CUSTOM_RSS,
|
||||
sourceUrl: linkMatch?.[1] || feedUrl,
|
||||
category: 'RSS Feed',
|
||||
sentiment: this.analyzeSentiment(title),
|
||||
importance: 0.5,
|
||||
tags: this.extractTags(title),
|
||||
publishedAt: dateMatch ? new Date(dateMatch[1]) : undefined,
|
||||
fetchedAt: new Date(),
|
||||
processed: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return articles;
|
||||
} catch (error) {
|
||||
console.error('RSS fetch error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze sentiment of text
|
||||
*/
|
||||
analyzeSentiment(text: string): number {
|
||||
if (!text) return 0;
|
||||
|
||||
const lowerText = text.toLowerCase();
|
||||
let bullishCount = 0;
|
||||
let bearishCount = 0;
|
||||
|
||||
// Count keyword occurrences
|
||||
SENTIMENT_KEYWORDS.bullish.forEach(keyword => {
|
||||
const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
|
||||
const matches = lowerText.match(regex);
|
||||
if (matches) bullishCount += matches.length;
|
||||
});
|
||||
|
||||
SENTIMENT_KEYWORDS.bearish.forEach(keyword => {
|
||||
const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
|
||||
const matches = lowerText.match(regex);
|
||||
if (matches) bearishCount += matches.length;
|
||||
});
|
||||
|
||||
// Calculate sentiment score (-1 to 1)
|
||||
const total = bullishCount + bearishCount;
|
||||
if (total === 0) return 0;
|
||||
|
||||
return (bullishCount - bearishCount) / total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sentiment label from score
|
||||
*/
|
||||
getSentimentLabel(score: number): SentimentLabel {
|
||||
if (score >= 0.6) return SentimentLabel.VERY_BULLISH;
|
||||
if (score >= 0.2) return SentimentLabel.BULLISH;
|
||||
if (score <= -0.6) return SentimentLabel.VERY_BEARISH;
|
||||
if (score <= -0.2) return SentimentLabel.BEARISH;
|
||||
return SentimentLabel.NEUTRAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize article based on content
|
||||
*/
|
||||
private categorizeArticle(title: string): string {
|
||||
const lowerTitle = title.toLowerCase();
|
||||
|
||||
for (const [category, keywords] of Object.entries(CATEGORIES)) {
|
||||
if (keywords.some(keyword => lowerTitle.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate article importance score
|
||||
*/
|
||||
private calculateImportance(article: Record<string, unknown>): number {
|
||||
let score = 0.5;
|
||||
|
||||
// Positive votes increase importance
|
||||
if (article.votes) {
|
||||
score += Math.min((article.votes as number) / 100, 0.3);
|
||||
}
|
||||
|
||||
// Comments indicate engagement
|
||||
if (article.comments) {
|
||||
score += Math.min((article.comments as number) / 50, 0.2);
|
||||
}
|
||||
|
||||
return Math.min(score, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate CryptoCompare article importance
|
||||
*/
|
||||
private calculateCryptoCompareImportance(article: Record<string, unknown>): number {
|
||||
let score = 0.5;
|
||||
|
||||
if (article.upvotes) {
|
||||
score += Math.min((article.upvotes as number) / 100, 0.3);
|
||||
}
|
||||
|
||||
if (article.downvotes) {
|
||||
score -= Math.min((article.downvotes as number) / 100, 0.2);
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(score, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tags from text
|
||||
*/
|
||||
private extractTags(text: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Extract mentioned cryptocurrencies
|
||||
const cryptoPatterns = [
|
||||
/\b(btc|bitcoin|eth|ethereum|sol|solana|ada|cardano|dot|polkadot|avax|avalanche)\b/gi,
|
||||
/\$[A-Z]{2,10}/g // Ticker symbols like $BTC, $ETH
|
||||
];
|
||||
|
||||
cryptoPatterns.forEach(pattern => {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
matches.forEach(m => tags.push(m.toUpperCase().replace('$', '')));
|
||||
}
|
||||
});
|
||||
|
||||
// Check for category keywords
|
||||
Object.entries(CATEGORIES).forEach(([category, keywords]) => {
|
||||
if (keywords.some(kw => lowerText.includes(kw))) {
|
||||
tags.push(category);
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate articles by URL
|
||||
*/
|
||||
private deduplicateArticles(articles: NewsArticle[]): NewsArticle[] {
|
||||
const seen = new Map<string, NewsArticle>();
|
||||
|
||||
articles.forEach(article => {
|
||||
const key = article.sourceUrl || article.title;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, article);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market-moving news (high importance)
|
||||
*/
|
||||
async getMarketMovingNews(limit: number = 10): Promise<NewsArticle[]> {
|
||||
const articles = await this.fetchAllNews({ limit: limit * 2 });
|
||||
|
||||
return articles
|
||||
.filter(a => (a.importance || 0) >= 0.7 || Math.abs(a.sentiment || 0) >= 0.5)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get news for specific trading pair
|
||||
*/
|
||||
async getNewsForSymbol(symbol: string, limit: number = 20): Promise<NewsArticle[]> {
|
||||
// Normalize symbol (BTCUSDT -> BTC)
|
||||
const baseAsset = symbol.replace('USDT', '').replace('USD', '').toUpperCase();
|
||||
|
||||
return this.fetchAllNews({
|
||||
symbols: [baseAsset],
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated sentiment for symbol
|
||||
*/
|
||||
async getSymbolSentiment(symbol: string): Promise<{
|
||||
overallSentiment: number;
|
||||
sentimentLabel: SentimentLabel;
|
||||
articleCount: number;
|
||||
bullishCount: number;
|
||||
bearishCount: number;
|
||||
neutralCount: number;
|
||||
topArticles: NewsArticle[];
|
||||
}> {
|
||||
const articles = await this.getNewsForSymbol(symbol);
|
||||
|
||||
let bullishCount = 0;
|
||||
let bearishCount = 0;
|
||||
let neutralCount = 0;
|
||||
let totalSentiment = 0;
|
||||
|
||||
articles.forEach(article => {
|
||||
const sentiment = article.sentiment || 0;
|
||||
totalSentiment += sentiment;
|
||||
|
||||
if (sentiment > 0.2) bullishCount++;
|
||||
else if (sentiment < -0.2) bearishCount++;
|
||||
else neutralCount++;
|
||||
});
|
||||
|
||||
const overallSentiment = articles.length > 0
|
||||
? totalSentiment / articles.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
overallSentiment,
|
||||
sentimentLabel: this.getSentimentLabel(overallSentiment),
|
||||
articleCount: articles.length,
|
||||
bullishCount,
|
||||
bearishCount,
|
||||
neutralCount,
|
||||
topArticles: articles.slice(0, 5)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const newsAggregator = new NewsAggregator();
|
||||
915
src/lib/trading/signals/signal-engine.ts
Normal file
915
src/lib/trading/signals/signal-engine.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
/**
|
||||
* Signal Generation Engine for Mantle AI Trading Bot
|
||||
* AI-powered signal generation with comprehensive analysis and rating system
|
||||
*/
|
||||
|
||||
import ZAI from 'z-ai-web-dev-sdk';
|
||||
import {
|
||||
Signal,
|
||||
SignalGenerationInput,
|
||||
SignalGenerationOutput,
|
||||
SignalAnalysis,
|
||||
TechnicalAnalysis,
|
||||
FundamentalAnalysis,
|
||||
SentimentAnalysis,
|
||||
RiskAssessment,
|
||||
TradeAction,
|
||||
RiskLevel,
|
||||
SentimentLabel,
|
||||
TimeFrame,
|
||||
MarketDataPoint,
|
||||
NewsArticle
|
||||
} from '../core/types';
|
||||
import { vectorStore } from '../../vector/vector-store';
|
||||
import { newsAggregator } from '../news/news-aggregator';
|
||||
|
||||
// Technical analysis helpers
|
||||
function calculateSMA(data: number[], period: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0);
|
||||
result.push(sum / period);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function calculateEMA(data: number[], period: number): number[] {
|
||||
const result: number[] = [];
|
||||
const multiplier = 2 / (period + 1);
|
||||
result[0] = data[0];
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
result[i] = (data[i] - result[i - 1]) * multiplier + result[i - 1];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function calculateRSI(closes: number[], period: number = 14): number {
|
||||
if (closes.length < period + 1) return 50;
|
||||
|
||||
let gains = 0;
|
||||
let losses = 0;
|
||||
|
||||
for (let i = closes.length - period; i < closes.length; i++) {
|
||||
const diff = closes[i] - closes[i - 1];
|
||||
if (diff > 0) gains += diff;
|
||||
else losses += Math.abs(diff);
|
||||
}
|
||||
|
||||
const avgGain = gains / period;
|
||||
const avgLoss = losses / period;
|
||||
|
||||
if (avgLoss === 0) return 100;
|
||||
|
||||
const rs = avgGain / avgLoss;
|
||||
return 100 - (100 / (1 + rs));
|
||||
}
|
||||
|
||||
function calculateMACD(closes: number[]): { macd: number; signal: number; histogram: number } {
|
||||
const ema12 = calculateEMA(closes, 12);
|
||||
const ema26 = calculateEMA(closes, 26);
|
||||
const macd = ema12[ema12.length - 1] - ema26[ema26.length - 1];
|
||||
const signal = calculateEMA([...Array(8).fill(macd), macd], 9)[8];
|
||||
const histogram = macd - signal;
|
||||
|
||||
return { macd, signal, histogram };
|
||||
}
|
||||
|
||||
function findSupportResistance(
|
||||
highs: number[],
|
||||
lows: number[],
|
||||
closes: number[]
|
||||
): { support: number[]; resistance: number[] } {
|
||||
const support: number[] = [];
|
||||
const resistance: number[] = [];
|
||||
|
||||
for (let i = 2; i < highs.length - 2; i++) {
|
||||
// Support: local low
|
||||
if (lows[i] < lows[i - 1] && lows[i] < lows[i - 2] &&
|
||||
lows[i] < lows[i + 1] && lows[i] < lows[i + 2]) {
|
||||
support.push(lows[i]);
|
||||
}
|
||||
|
||||
// Resistance: local high
|
||||
if (highs[i] > highs[i - 1] && highs[i] > highs[i - 2] &&
|
||||
highs[i] > highs[i + 1] && highs[i] > highs[i + 2]) {
|
||||
resistance.push(highs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and deduplicate
|
||||
support.sort((a, b) => b - a);
|
||||
resistance.sort((a, b) => a - b);
|
||||
|
||||
return {
|
||||
support: [...new Set(support)].slice(0, 5),
|
||||
resistance: [...new Set(resistance)].slice(0, 5)
|
||||
};
|
||||
}
|
||||
|
||||
function detectPatterns(
|
||||
opens: number[],
|
||||
highs: number[],
|
||||
lows: number[],
|
||||
closes: number[]
|
||||
): string[] {
|
||||
const patterns: string[] = [];
|
||||
const len = closes.length;
|
||||
|
||||
if (len < 5) return patterns;
|
||||
|
||||
// Doji
|
||||
const lastOpen = opens[len - 1];
|
||||
const lastClose = closes[len - 1];
|
||||
const lastHigh = highs[len - 1];
|
||||
const lastLow = lows[len - 1];
|
||||
const bodySize = Math.abs(lastClose - lastOpen);
|
||||
const range = lastHigh - lastLow;
|
||||
|
||||
if (bodySize < range * 0.1) {
|
||||
patterns.push('DOJI');
|
||||
}
|
||||
|
||||
// Hammer
|
||||
if (bodySize < range * 0.3 &&
|
||||
lastLow < Math.min(lastOpen, lastClose) - range * 0.6) {
|
||||
patterns.push('HAMMER');
|
||||
}
|
||||
|
||||
// Bullish Engulfing
|
||||
if (len >= 2) {
|
||||
const prevClose = closes[len - 2];
|
||||
const prevOpen = opens[len - 2];
|
||||
|
||||
if (prevClose < prevOpen && // Previous bearish
|
||||
lastClose > lastOpen && // Current bullish
|
||||
lastOpen < prevClose && // Opens below prev close
|
||||
lastClose > prevOpen) { // Closes above prev open
|
||||
patterns.push('BULLISH_ENGULFING');
|
||||
}
|
||||
}
|
||||
|
||||
// Bearish Engulfing
|
||||
if (len >= 2) {
|
||||
const prevClose = closes[len - 2];
|
||||
const prevOpen = opens[len - 2];
|
||||
|
||||
if (prevClose > prevOpen && // Previous bullish
|
||||
lastClose < lastOpen && // Current bearish
|
||||
lastOpen > prevClose && // Opens above prev close
|
||||
lastClose < prevOpen) { // Closes below prev open
|
||||
patterns.push('BEARISH_ENGULFING');
|
||||
}
|
||||
}
|
||||
|
||||
// Morning Star / Evening Star
|
||||
if (len >= 3) {
|
||||
const firstClose = closes[len - 3];
|
||||
const firstOpen = opens[len - 3];
|
||||
const secondClose = closes[len - 2];
|
||||
const secondOpen = opens[len - 2];
|
||||
|
||||
if (firstClose < firstOpen && // First bearish
|
||||
Math.abs(secondClose - secondOpen) < (secondHigh(secondOpen, secondClose) - secondLow(secondOpen, secondClose)) * 0.3 && // Small body
|
||||
lastClose > lastOpen && lastClose > (firstOpen + firstClose) / 2) { // Bullish third
|
||||
patterns.push('MORNING_STAR');
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
function secondHigh(open: number, close: number): number {
|
||||
return Math.max(open, close);
|
||||
}
|
||||
|
||||
function secondLow(open: number, close: number): number {
|
||||
return Math.min(open, close);
|
||||
}
|
||||
|
||||
export class SignalEngine {
|
||||
private zai: Awaited<ReturnType<typeof ZAI.create>> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.initAI();
|
||||
}
|
||||
|
||||
private async initAI(): Promise<void> {
|
||||
try {
|
||||
this.zai = await ZAI.create();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize AI:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trading signal with comprehensive analysis
|
||||
*/
|
||||
async generateSignal(input: SignalGenerationInput): Promise<SignalGenerationOutput> {
|
||||
// Perform technical analysis
|
||||
const technicalAnalysis = this.performTechnicalAnalysis(input.marketData);
|
||||
|
||||
// Perform fundamental analysis
|
||||
const fundamentalAnalysis = await this.performFundamentalAnalysis(
|
||||
input.symbol,
|
||||
input.newsArticles
|
||||
);
|
||||
|
||||
// Perform sentiment analysis
|
||||
const sentimentAnalysis = await this.performSentimentAnalysis(
|
||||
input.symbol,
|
||||
input.newsArticles
|
||||
);
|
||||
|
||||
// Calculate overall score
|
||||
const overallScore = this.calculateOverallScore(
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis
|
||||
);
|
||||
|
||||
// Determine action
|
||||
const action = this.determineAction(
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis,
|
||||
overallScore
|
||||
);
|
||||
|
||||
// Generate reasoning using AI
|
||||
const reasoning = await this.generateReasoning(
|
||||
input.symbol,
|
||||
action,
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis
|
||||
);
|
||||
|
||||
// Calculate confidence
|
||||
const confidence = this.calculateConfidence(
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis,
|
||||
overallScore
|
||||
);
|
||||
|
||||
// Assess risk
|
||||
const riskAssessment = this.assessRisk(
|
||||
input.symbol,
|
||||
action,
|
||||
technicalAnalysis,
|
||||
input.marketData
|
||||
);
|
||||
|
||||
// Calculate targets
|
||||
const currentPrice = input.marketData[input.marketData.length - 1]?.close || 0;
|
||||
const { priceTarget, stopLoss, takeProfit } = this.calculateTargets(
|
||||
action,
|
||||
currentPrice,
|
||||
technicalAnalysis,
|
||||
riskAssessment
|
||||
);
|
||||
|
||||
// Build signal
|
||||
const signal: Omit<Signal, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
symbol: input.symbol,
|
||||
action,
|
||||
confidence,
|
||||
rating: 0,
|
||||
priceTarget,
|
||||
stopLoss,
|
||||
takeProfit,
|
||||
reasoning,
|
||||
newsSources: input.newsArticles.slice(0, 5).map(a => a.sourceUrl).filter(Boolean) as string[],
|
||||
sentimentScore: sentimentAnalysis.overallSentiment,
|
||||
technicalScore: technicalAnalysis.score,
|
||||
fundamentalScore: fundamentalAnalysis.score,
|
||||
status: 'PENDING' as Signal['status'],
|
||||
demo: false
|
||||
};
|
||||
|
||||
// Build analysis
|
||||
const analysis: SignalAnalysis = {
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis,
|
||||
overallScore,
|
||||
keyFactors: this.extractKeyFactors(
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis
|
||||
),
|
||||
warnings: this.generateWarnings(
|
||||
technicalAnalysis,
|
||||
fundamentalAnalysis,
|
||||
sentimentAnalysis,
|
||||
riskAssessment
|
||||
)
|
||||
};
|
||||
|
||||
return {
|
||||
signal,
|
||||
analysis,
|
||||
riskAssessment
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform technical analysis on market data
|
||||
*/
|
||||
private performTechnicalAnalysis(data: MarketDataPoint[]): TechnicalAnalysis {
|
||||
if (data.length < 50) {
|
||||
return {
|
||||
trend: 'SIDEWAYS',
|
||||
trendStrength: 0,
|
||||
supportLevels: [],
|
||||
resistanceLevels: [],
|
||||
indicators: {},
|
||||
patterns: [],
|
||||
score: 0.5
|
||||
};
|
||||
}
|
||||
|
||||
const closes = data.map(d => d.close);
|
||||
const highs = data.map(d => d.high);
|
||||
const lows = data.map(d => d.low);
|
||||
const opens = data.map(d => d.open);
|
||||
const volumes = data.map(d => d.volume);
|
||||
|
||||
// Calculate indicators
|
||||
const sma20 = calculateSMA(closes, 20);
|
||||
const sma50 = calculateSMA(closes, 50);
|
||||
const ema12 = calculateEMA(closes, 12);
|
||||
const ema26 = calculateEMA(closes, 26);
|
||||
const rsi = calculateRSI(closes);
|
||||
const macd = calculateMACD(closes);
|
||||
|
||||
// Determine trend
|
||||
const lastClose = closes[closes.length - 1];
|
||||
const lastSma20 = sma20[sma20.length - 1];
|
||||
const lastSma50 = sma50[sma50.length - 1];
|
||||
|
||||
let trend: 'BULLISH' | 'BEARISH' | 'SIDEWAYS' = 'SIDEWAYS';
|
||||
let trendStrength = 0;
|
||||
|
||||
if (lastClose > lastSma20 && lastSma20 > lastSma50) {
|
||||
trend = 'BULLISH';
|
||||
trendStrength = Math.min((lastClose - lastSma50) / lastSma50, 1);
|
||||
} else if (lastClose < lastSma20 && lastSma20 < lastSma50) {
|
||||
trend = 'BEARISH';
|
||||
trendStrength = Math.min((lastSma50 - lastClose) / lastSma50, 1);
|
||||
}
|
||||
|
||||
// Find support and resistance
|
||||
const { support, resistance } = findSupportResistance(highs, lows, closes);
|
||||
|
||||
// Detect patterns
|
||||
const patterns = detectPatterns(opens, highs, lows, closes);
|
||||
|
||||
// Calculate score
|
||||
let score = 0.5;
|
||||
|
||||
// RSI contribution
|
||||
if (rsi > 70) score -= 0.15; // Overbought
|
||||
else if (rsi < 30) score += 0.15; // Oversold
|
||||
else if (rsi > 50) score += 0.1;
|
||||
|
||||
// MACD contribution
|
||||
if (macd.histogram > 0) score += 0.1;
|
||||
else score -= 0.1;
|
||||
|
||||
// Trend contribution
|
||||
if (trend === 'BULLISH') score += 0.15 * trendStrength;
|
||||
else if (trend === 'BEARISH') score -= 0.15 * trendStrength;
|
||||
|
||||
// Pattern contribution
|
||||
if (patterns.includes('HAMMER') || patterns.includes('MORNING_STAR') || patterns.includes('BULLISH_ENGULFING')) {
|
||||
score += 0.1;
|
||||
}
|
||||
if (patterns.includes('BEARISH_ENGULFING')) {
|
||||
score -= 0.1;
|
||||
}
|
||||
|
||||
// Volume analysis
|
||||
const avgVolume = volumes.slice(-20).reduce((a, b) => a + b, 0) / 20;
|
||||
const lastVolume = volumes[volumes.length - 1];
|
||||
if (lastVolume > avgVolume * 1.5) {
|
||||
// High volume confirms trend
|
||||
if (trend === 'BULLISH') score += 0.05;
|
||||
else if (trend === 'BEARISH') score -= 0.05;
|
||||
}
|
||||
|
||||
return {
|
||||
trend,
|
||||
trendStrength,
|
||||
supportLevels: support,
|
||||
resistanceLevels: resistance,
|
||||
indicators: {
|
||||
rsi,
|
||||
macd: macd.macd,
|
||||
macdSignal: macd.signal,
|
||||
macdHistogram: macd.histogram,
|
||||
sma20: lastSma20,
|
||||
sma50: lastSma50,
|
||||
ema12: ema12[ema12.length - 1],
|
||||
ema26: ema26[ema26.length - 1]
|
||||
},
|
||||
patterns,
|
||||
score: Math.max(0, Math.min(1, score))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform fundamental analysis
|
||||
*/
|
||||
private async performFundamentalAnalysis(
|
||||
symbol: string,
|
||||
newsArticles: NewsArticle[]
|
||||
): Promise<FundamentalAnalysis> {
|
||||
// Get news sentiment
|
||||
const newsImpact = this.calculateNewsImpact(newsArticles);
|
||||
|
||||
// Extract market events
|
||||
const marketEvents = this.extractMarketEvents(newsArticles);
|
||||
|
||||
// Economic factors
|
||||
const economicFactors = this.identifyEconomicFactors(newsArticles);
|
||||
|
||||
// Calculate score based on news
|
||||
let score = 0.5;
|
||||
|
||||
// Positive events boost score
|
||||
marketEvents.forEach(event => {
|
||||
if (event.toLowerCase().includes('partnership') ||
|
||||
event.toLowerCase().includes('adoption') ||
|
||||
event.toLowerCase().includes('launch')) {
|
||||
score += 0.05;
|
||||
}
|
||||
if (event.toLowerCase().includes('hack') ||
|
||||
event.toLowerCase().includes('regulation') ||
|
||||
event.toLowerCase().includes('ban')) {
|
||||
score -= 0.05;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
newsImpact,
|
||||
marketEvents,
|
||||
economicFactors,
|
||||
score: Math.max(0, Math.min(1, score))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform sentiment analysis
|
||||
*/
|
||||
private async performSentimentAnalysis(
|
||||
symbol: string,
|
||||
newsArticles: NewsArticle[]
|
||||
): Promise<SentimentAnalysis> {
|
||||
// Get sentiment from news aggregator
|
||||
const symbolSentiment = await newsAggregator.getSymbolSentiment(symbol);
|
||||
|
||||
// Get contextual sentiment from vector store
|
||||
const contextSentiment = await vectorStore.analyzeSentimentWithContext(
|
||||
`${symbol} trading analysis`
|
||||
);
|
||||
|
||||
// Combine sentiments
|
||||
const overallSentiment = (
|
||||
symbolSentiment.overallSentiment +
|
||||
contextSentiment.sentiment
|
||||
) / 2;
|
||||
|
||||
// Determine label
|
||||
let sentimentLabel = SentimentLabel.NEUTRAL;
|
||||
if (overallSentiment >= 0.3) sentimentLabel = SentimentLabel.BULLISH;
|
||||
else if (overallSentiment >= 0.6) sentimentLabel = SentimentLabel.VERY_BULLISH;
|
||||
else if (overallSentiment <= -0.3) sentimentLabel = SentimentLabel.BEARISH;
|
||||
else if (overallSentiment <= -0.6) sentimentLabel = SentimentLabel.VERY_BEARISH;
|
||||
|
||||
// Extract key topics
|
||||
const keyTopics = this.extractKeyTopics(newsArticles);
|
||||
|
||||
return {
|
||||
overallSentiment,
|
||||
sentimentLabel,
|
||||
newsSentiment: symbolSentiment.overallSentiment,
|
||||
socialSentiment: contextSentiment.sentiment,
|
||||
keyTopics,
|
||||
trendingKeywords: keyTopics
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall score
|
||||
*/
|
||||
private calculateOverallScore(
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis
|
||||
): number {
|
||||
const weights = {
|
||||
technical: 0.4,
|
||||
fundamental: 0.3,
|
||||
sentiment: 0.3
|
||||
};
|
||||
|
||||
return (
|
||||
technical.score * weights.technical +
|
||||
fundamental.score * weights.fundamental +
|
||||
((sentiment.overallSentiment + 1) / 2) * weights.sentiment
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine trading action
|
||||
*/
|
||||
private determineAction(
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis,
|
||||
overallScore: number
|
||||
): TradeAction {
|
||||
// Strong buy conditions
|
||||
if (overallScore >= 0.7 &&
|
||||
technical.trend === 'BULLISH' &&
|
||||
sentiment.sentimentLabel === SentimentLabel.BULLISH) {
|
||||
return TradeAction.BUY;
|
||||
}
|
||||
|
||||
// Strong sell conditions
|
||||
if (overallScore <= 0.3 &&
|
||||
technical.trend === 'BEARISH' &&
|
||||
sentiment.sentimentLabel === SentimentLabel.BEARISH) {
|
||||
return TradeAction.SELL;
|
||||
}
|
||||
|
||||
// Moderate buy
|
||||
if (overallScore >= 0.6 &&
|
||||
technical.indicators.rsi < 70 &&
|
||||
sentiment.overallSentiment > 0) {
|
||||
return TradeAction.BUY;
|
||||
}
|
||||
|
||||
// Moderate sell
|
||||
if (overallScore <= 0.4 &&
|
||||
technical.indicators.rsi > 30 &&
|
||||
sentiment.overallSentiment < 0) {
|
||||
return TradeAction.SELL;
|
||||
}
|
||||
|
||||
return TradeAction.HOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI reasoning
|
||||
*/
|
||||
private async generateReasoning(
|
||||
symbol: string,
|
||||
action: TradeAction,
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis
|
||||
): Promise<string> {
|
||||
if (!this.zai) {
|
||||
return this.generateBasicReasoning(
|
||||
symbol, action, technical, fundamental, sentiment
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const prompt = `Analyze the following trading signal for ${symbol} and provide a brief, professional reasoning (2-3 sentences):
|
||||
|
||||
Action: ${action}
|
||||
Technical Analysis: Trend is ${technical.trend} with ${Math.round(technical.trendStrength * 100)}% strength. RSI: ${technical.indicators.rsi?.toFixed(1) || 'N/A'}. Patterns detected: ${technical.patterns.join(', ') || 'None'}.
|
||||
Fundamental Analysis: News impact score: ${(fundamental.newsImpact * 100).toFixed(0)}%. Key events: ${fundamental.marketEvents.slice(0, 3).join(', ') || 'None significant'}.
|
||||
Sentiment: ${sentiment.sentimentLabel} (${(sentiment.overallSentiment * 100).toFixed(0)}%)
|
||||
|
||||
Provide a concise trading rationale:`;
|
||||
|
||||
const completion = await this.zai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a professional trading analyst. Provide concise, actionable insights.' },
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
return completion.choices[0]?.message?.content ||
|
||||
this.generateBasicReasoning(symbol, action, technical, fundamental, sentiment);
|
||||
} catch (error) {
|
||||
return this.generateBasicReasoning(symbol, action, technical, fundamental, sentiment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate basic reasoning without AI
|
||||
*/
|
||||
private generateBasicReasoning(
|
||||
symbol: string,
|
||||
action: TradeAction,
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis
|
||||
): string {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (technical.trend === 'BULLISH' && action === TradeAction.BUY) {
|
||||
reasons.push(`Bullish trend detected with ${Math.round(technical.trendStrength * 100)}% strength`);
|
||||
}
|
||||
if (technical.trend === 'BEARISH' && action === TradeAction.SELL) {
|
||||
reasons.push(`Bearish trend detected with ${Math.round(technical.trendStrength * 100)}% strength`);
|
||||
}
|
||||
if (technical.indicators.rsi && technical.indicators.rsi < 30) {
|
||||
reasons.push('RSI indicates oversold conditions');
|
||||
}
|
||||
if (technical.indicators.rsi && technical.indicators.rsi > 70) {
|
||||
reasons.push('RSI indicates overbought conditions');
|
||||
}
|
||||
if (sentiment.sentimentLabel === SentimentLabel.BULLISH) {
|
||||
reasons.push('Market sentiment is bullish');
|
||||
}
|
||||
if (sentiment.sentimentLabel === SentimentLabel.BEARISH) {
|
||||
reasons.push('Market sentiment is bearish');
|
||||
}
|
||||
|
||||
return reasons.length > 0
|
||||
? `${symbol}: ${action} signal. ${reasons.join('. ')}.`
|
||||
: `${symbol}: ${action} signal based on mixed indicators.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence
|
||||
*/
|
||||
private calculateConfidence(
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis,
|
||||
overallScore: number
|
||||
): number {
|
||||
// Base confidence from overall score
|
||||
let confidence = Math.abs(overallScore - 0.5) * 2;
|
||||
|
||||
// Boost confidence when all indicators align
|
||||
const technicalDirection = technical.score > 0.5 ? 1 : -1;
|
||||
const sentimentDirection = sentiment.overallSentiment > 0 ? 1 : -1;
|
||||
|
||||
if (technicalDirection === sentimentDirection) {
|
||||
confidence *= 1.2;
|
||||
}
|
||||
|
||||
// Reduce confidence during high volatility (wide support/resistance range)
|
||||
if (technical.supportLevels.length > 0 && technical.resistanceLevels.length > 0) {
|
||||
const range = technical.resistanceLevels[0] - technical.supportLevels[0];
|
||||
const midPrice = (technical.resistanceLevels[0] + technical.supportLevels[0]) / 2;
|
||||
const rangePercent = range / midPrice;
|
||||
|
||||
if (rangePercent > 0.1) {
|
||||
confidence *= 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(1, confidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assess risk
|
||||
*/
|
||||
private assessRisk(
|
||||
symbol: string,
|
||||
action: TradeAction,
|
||||
technical: TechnicalAnalysis,
|
||||
marketData: MarketDataPoint[]
|
||||
): RiskAssessment {
|
||||
const lastPrice = marketData[marketData.length - 1]?.close || 0;
|
||||
|
||||
// Calculate volatility
|
||||
const returns = marketData.slice(-20).map((d, i, arr) => {
|
||||
if (i === 0) return 0;
|
||||
return (d.close - arr[i - 1].close) / arr[i - 1].close;
|
||||
});
|
||||
const volatility = Math.sqrt(
|
||||
returns.reduce((sum, r) => sum + r * r, 0) / returns.length
|
||||
);
|
||||
|
||||
// Determine risk level
|
||||
let riskLevel = RiskLevel.MODERATE;
|
||||
let riskScore = 0.5;
|
||||
|
||||
if (volatility > 0.05) {
|
||||
riskLevel = RiskLevel.AGGRESSIVE;
|
||||
riskScore = 0.7;
|
||||
} else if (volatility < 0.02) {
|
||||
riskLevel = RiskLevel.CONSERVATIVE;
|
||||
riskScore = 0.3;
|
||||
}
|
||||
|
||||
// Calculate suggested levels
|
||||
const suggestedStopLoss = action === TradeAction.BUY
|
||||
? lastPrice * (1 - volatility * 2)
|
||||
: lastPrice * (1 + volatility * 2);
|
||||
|
||||
const suggestedTakeProfit = action === TradeAction.BUY
|
||||
? lastPrice * (1 + volatility * 3)
|
||||
: lastPrice * (1 - volatility * 3);
|
||||
|
||||
// Risk factors
|
||||
const riskFactors: string[] = [];
|
||||
if (technical.indicators.rsi && technical.indicators.rsi > 70) {
|
||||
riskFactors.push('Overbought conditions');
|
||||
}
|
||||
if (technical.indicators.rsi && technical.indicators.rsi < 30) {
|
||||
riskFactors.push('Oversold conditions');
|
||||
}
|
||||
if (volatility > 0.04) {
|
||||
riskFactors.push('High market volatility');
|
||||
}
|
||||
if (technical.patterns.includes('DOJI')) {
|
||||
riskFactors.push('Indecision pattern detected');
|
||||
}
|
||||
|
||||
return {
|
||||
riskScore,
|
||||
riskLevel,
|
||||
maxRecommendedPosition: lastPrice * 10, // $10 worth at current price
|
||||
suggestedStopLoss,
|
||||
suggestedTakeProfit,
|
||||
riskFactors,
|
||||
marketVolatility: volatility,
|
||||
liquidityRisk: 0.2 // Assume good liquidity for major pairs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate price targets
|
||||
*/
|
||||
private calculateTargets(
|
||||
action: TradeAction,
|
||||
currentPrice: number,
|
||||
technical: TechnicalAnalysis,
|
||||
risk: RiskAssessment
|
||||
): { priceTarget: number; stopLoss: number; takeProfit: number } {
|
||||
if (action === TradeAction.HOLD) {
|
||||
return {
|
||||
priceTarget: currentPrice,
|
||||
stopLoss: currentPrice,
|
||||
takeProfit: currentPrice
|
||||
};
|
||||
}
|
||||
|
||||
// Use support/resistance if available
|
||||
let priceTarget = risk.suggestedTakeProfit;
|
||||
let stopLoss = risk.suggestedStopLoss;
|
||||
|
||||
if (action === TradeAction.BUY) {
|
||||
// Target nearest resistance
|
||||
if (technical.resistanceLevels.length > 0) {
|
||||
priceTarget = Math.min(
|
||||
technical.resistanceLevels[0],
|
||||
risk.suggestedTakeProfit
|
||||
);
|
||||
}
|
||||
// Stop at nearest support
|
||||
if (technical.supportLevels.length > 0) {
|
||||
stopLoss = Math.max(
|
||||
technical.supportLevels[0],
|
||||
risk.suggestedStopLoss
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Target nearest support
|
||||
if (technical.supportLevels.length > 0) {
|
||||
priceTarget = Math.max(
|
||||
technical.supportLevels[0],
|
||||
risk.suggestedTakeProfit
|
||||
);
|
||||
}
|
||||
// Stop at nearest resistance
|
||||
if (technical.resistanceLevels.length > 0) {
|
||||
stopLoss = Math.min(
|
||||
technical.resistanceLevels[0],
|
||||
risk.suggestedStopLoss
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const takeProfit = priceTarget;
|
||||
|
||||
return { priceTarget, stopLoss, takeProfit };
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private calculateNewsImpact(articles: NewsArticle[]): number {
|
||||
if (articles.length === 0) return 0;
|
||||
|
||||
const totalImportance = articles.reduce(
|
||||
(sum, a) => sum + (a.importance || 0.5),
|
||||
0
|
||||
);
|
||||
|
||||
return Math.min(totalImportance / articles.length, 1);
|
||||
}
|
||||
|
||||
private extractMarketEvents(articles: NewsArticle[]): string[] {
|
||||
return articles
|
||||
.filter(a => a.importance && a.importance > 0.6)
|
||||
.slice(0, 10)
|
||||
.map(a => a.title);
|
||||
}
|
||||
|
||||
private identifyEconomicFactors(articles: NewsArticle[]): string[] {
|
||||
const factors: string[] = [];
|
||||
const keywords = ['inflation', 'interest rate', 'fed', 'regulation', 'adoption', 'institutional'];
|
||||
|
||||
articles.forEach(article => {
|
||||
const text = article.title.toLowerCase();
|
||||
keywords.forEach(kw => {
|
||||
if (text.includes(kw)) {
|
||||
factors.push(kw);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return [...new Set(factors)];
|
||||
}
|
||||
|
||||
private extractKeyTopics(articles: NewsArticle[]): string[] {
|
||||
const topics: string[] = [];
|
||||
|
||||
articles.forEach(article => {
|
||||
if (article.tags) {
|
||||
topics.push(...article.tags);
|
||||
}
|
||||
});
|
||||
|
||||
// Return most frequent topics
|
||||
const frequency: Record<string, number> = {};
|
||||
topics.forEach(t => {
|
||||
frequency[t] = (frequency[t] || 0) + 1;
|
||||
});
|
||||
|
||||
return Object.entries(frequency)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([topic]) => topic);
|
||||
}
|
||||
|
||||
private extractKeyFactors(
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis
|
||||
): string[] {
|
||||
const factors: string[] = [];
|
||||
|
||||
if (technical.trend !== 'SIDEWAYS') {
|
||||
factors.push(`${technical.trend} trend (${Math.round(technical.trendStrength * 100)}% strength)`);
|
||||
}
|
||||
if (technical.patterns.length > 0) {
|
||||
factors.push(`Patterns: ${technical.patterns.join(', ')}`);
|
||||
}
|
||||
if (fundamental.marketEvents.length > 0) {
|
||||
factors.push(`Key events: ${fundamental.marketEvents.slice(0, 2).join(', ')}`);
|
||||
}
|
||||
if (sentiment.sentimentLabel !== SentimentLabel.NEUTRAL) {
|
||||
factors.push(`Sentiment: ${sentiment.sentimentLabel}`);
|
||||
}
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
private generateWarnings(
|
||||
technical: TechnicalAnalysis,
|
||||
fundamental: FundamentalAnalysis,
|
||||
sentiment: SentimentAnalysis,
|
||||
risk: RiskAssessment
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (technical.indicators.rsi && technical.indicators.rsi > 70) {
|
||||
warnings.push('RSI indicates overbought conditions - potential reversal risk');
|
||||
}
|
||||
if (technical.indicators.rsi && technical.indicators.rsi < 30) {
|
||||
warnings.push('RSI indicates oversold conditions - may continue falling');
|
||||
}
|
||||
if (risk.marketVolatility > 0.05) {
|
||||
warnings.push('High market volatility - use smaller position sizes');
|
||||
}
|
||||
if (risk.riskFactors.includes('Indecision pattern detected')) {
|
||||
warnings.push('Market showing indecision - wait for clearer signals');
|
||||
}
|
||||
if (fundamental.marketEvents.some(e =>
|
||||
e.toLowerCase().includes('regulation') ||
|
||||
e.toLowerCase().includes('ban')
|
||||
)) {
|
||||
warnings.push('Regulatory news may cause volatility');
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const signalEngine = new SignalEngine();
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
443
src/lib/vector/vector-store.ts
Normal file
443
src/lib/vector/vector-store.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Vector Database Service for Mantle AI Trading Bot
|
||||
* ChromaDB integration for semantic search of news and analysis
|
||||
*/
|
||||
|
||||
import { ChromaClient, Collection, IncludeEnum } from 'chromadb-client';
|
||||
import { NewsArticle, Signal, SignalAnalysis, SentimentLabel } from '../trading/core/types';
|
||||
|
||||
// Collection names
|
||||
const COLLECTIONS = {
|
||||
NEWS: 'trading_news',
|
||||
SIGNALS: 'trading_signals',
|
||||
ANALYSIS: 'signal_analysis'
|
||||
};
|
||||
|
||||
// Embedding dimension (for typical embedding models)
|
||||
const EMBEDDING_DIMENSION = 384;
|
||||
|
||||
export class VectorStore {
|
||||
private client: ChromaClient | null = null;
|
||||
private newsCollection: Collection | null = null;
|
||||
private signalsCollection: Collection | null = null;
|
||||
private analysisCollection: Collection | null = null;
|
||||
private connected = false;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ChromaDB connection
|
||||
*/
|
||||
private async init(): Promise<void> {
|
||||
try {
|
||||
// Try to connect to ChromaDB server
|
||||
this.client = new ChromaClient({
|
||||
path: process.env.CHROMADB_URL || 'http://localhost:8000'
|
||||
});
|
||||
|
||||
// Create or get collections
|
||||
await this.createCollections();
|
||||
this.connected = true;
|
||||
console.log('VectorStore: Connected to ChromaDB');
|
||||
} catch (error) {
|
||||
console.warn('VectorStore: ChromaDB not available, using fallback mode');
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get collections
|
||||
*/
|
||||
private async createCollections(): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
// News collection
|
||||
this.newsCollection = await this.client.getOrCreateCollection({
|
||||
name: COLLECTIONS.NEWS,
|
||||
metadata: { description: 'Trading news articles for semantic search' }
|
||||
});
|
||||
|
||||
// Signals collection
|
||||
this.signalsCollection = await this.client.getOrCreateCollection({
|
||||
name: COLLECTIONS.SIGNALS,
|
||||
metadata: { description: 'Historical trading signals' }
|
||||
});
|
||||
|
||||
// Analysis collection
|
||||
this.analysisCollection = await this.client.getOrCreateCollection({
|
||||
name: COLLECTIONS.ANALYSIS,
|
||||
metadata: { description: 'Signal analysis and reasoning' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate simple embedding (fallback when no embedding model)
|
||||
* This creates a deterministic embedding based on text content
|
||||
*/
|
||||
private generateSimpleEmbedding(text: string): number[] {
|
||||
const embedding = new Array(EMBEDDING_DIMENSION).fill(0);
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
|
||||
words.forEach((word, index) => {
|
||||
// Simple hash-based embedding
|
||||
const hash = this.simpleHash(word);
|
||||
const pos = Math.abs(hash) % EMBEDDING_DIMENSION;
|
||||
embedding[pos] += 1;
|
||||
|
||||
// Add positional encoding
|
||||
const pos2 = (pos + index) % EMBEDDING_DIMENSION;
|
||||
embedding[pos2] += 0.5;
|
||||
});
|
||||
|
||||
// Normalize
|
||||
const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0)) || 1;
|
||||
return embedding.map(val => val / norm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string hash
|
||||
*/
|
||||
private simpleHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store news article in vector database
|
||||
*/
|
||||
async storeNewsArticle(article: NewsArticle): Promise<string | null> {
|
||||
if (!this.newsCollection || !this.connected) {
|
||||
// Store article ID as vector reference
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = article.id || `news-${Date.now()}`;
|
||||
const text = `${article.title} ${article.content || ''}`;
|
||||
const embedding = this.generateSimpleEmbedding(text);
|
||||
|
||||
await this.newsCollection.add({
|
||||
ids: [id],
|
||||
embeddings: [embedding],
|
||||
metadatas: [{
|
||||
title: article.title,
|
||||
source: article.source,
|
||||
category: article.category || 'General',
|
||||
sentiment: article.sentiment || 0,
|
||||
importance: article.importance || 0.5,
|
||||
publishedAt: article.publishedAt?.toISOString() || new Date().toISOString(),
|
||||
url: article.sourceUrl || ''
|
||||
}],
|
||||
documents: [text]
|
||||
});
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Error storing news article:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store multiple news articles
|
||||
*/
|
||||
async storeNewsArticles(articles: NewsArticle[]): Promise<string[]> {
|
||||
if (!this.newsCollection || !this.connected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
const embeddings: number[][] = [];
|
||||
const metadatas: Record<string, unknown>[] = [];
|
||||
const documents: string[] = [];
|
||||
|
||||
articles.forEach(article => {
|
||||
const id = article.id || `news-${Date.now()}-${Math.random()}`;
|
||||
const text = `${article.title} ${article.content || ''}`;
|
||||
|
||||
ids.push(id);
|
||||
embeddings.push(this.generateSimpleEmbedding(text));
|
||||
metadatas.push({
|
||||
title: article.title,
|
||||
source: article.source,
|
||||
category: article.category || 'General',
|
||||
sentiment: article.sentiment || 0,
|
||||
importance: article.importance || 0.5,
|
||||
publishedAt: article.publishedAt?.toISOString() || new Date().toISOString(),
|
||||
url: article.sourceUrl || ''
|
||||
});
|
||||
documents.push(text);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.newsCollection.add({
|
||||
ids,
|
||||
embeddings,
|
||||
metadatas,
|
||||
documents
|
||||
});
|
||||
|
||||
return ids;
|
||||
} catch (error) {
|
||||
console.error('Error storing news articles:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search similar news articles
|
||||
*/
|
||||
async searchSimilarNews(
|
||||
query: string,
|
||||
nResults: number = 10,
|
||||
filters?: Record<string, unknown>
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
metadata: Record<string, unknown>;
|
||||
distance: number;
|
||||
}>> {
|
||||
if (!this.newsCollection || !this.connected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryEmbedding = this.generateSimpleEmbedding(query);
|
||||
|
||||
const results = await this.newsCollection.query({
|
||||
queryEmbeddings: [queryEmbedding],
|
||||
nResults,
|
||||
where: filters,
|
||||
include: [IncludeEnum.Documents, IncludeEnum.Metadatas, IncludeEnum.Distances]
|
||||
});
|
||||
|
||||
if (!results.ids[0]) return [];
|
||||
|
||||
return results.ids[0].map((id, index) => ({
|
||||
id,
|
||||
text: results.documents?.[0]?.[index] || '',
|
||||
metadata: results.metadatas?.[0]?.[index] || {},
|
||||
distance: results.distances?.[0]?.[index] || 0
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching news:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store signal with analysis
|
||||
*/
|
||||
async storeSignalAnalysis(
|
||||
signal: Signal,
|
||||
analysis: SignalAnalysis
|
||||
): Promise<void> {
|
||||
if (!this.signalsCollection || !this.analysisCollection || !this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store signal
|
||||
const signalText = `${signal.symbol} ${signal.action} ${signal.reasoning}`;
|
||||
await this.signalsCollection.add({
|
||||
ids: [signal.id],
|
||||
embeddings: [this.generateSimpleEmbedding(signalText)],
|
||||
metadatas: [{
|
||||
symbol: signal.symbol,
|
||||
action: signal.action,
|
||||
confidence: signal.confidence,
|
||||
status: signal.status,
|
||||
result: signal.result || 'PENDING',
|
||||
pnl: signal.resultPnL || 0,
|
||||
createdAt: signal.createdAt.toISOString()
|
||||
}],
|
||||
documents: [signalText]
|
||||
});
|
||||
|
||||
// Store analysis
|
||||
const analysisText = analysis.keyFactors.join(' ') + ' ' +
|
||||
analysis.technicalAnalysis.patterns.join(' ') + ' ' +
|
||||
analysis.fundamentalAnalysis.marketEvents.join(' ');
|
||||
|
||||
await this.analysisCollection.add({
|
||||
ids: [`analysis-${signal.id}`],
|
||||
embeddings: [this.generateSimpleEmbedding(analysisText)],
|
||||
metadatas: [{
|
||||
signalId: signal.id,
|
||||
overallScore: analysis.overallScore,
|
||||
technicalScore: analysis.technicalAnalysis.score,
|
||||
fundamentalScore: analysis.fundamentalAnalysis.score,
|
||||
sentimentScore: analysis.sentimentAnalysis.overallSentiment
|
||||
}],
|
||||
documents: [analysisText]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error storing signal analysis:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar historical signals
|
||||
*/
|
||||
async findSimilarSignals(
|
||||
symbol: string,
|
||||
action: string,
|
||||
reasoning: string,
|
||||
nResults: number = 5
|
||||
): Promise<Array<{
|
||||
signalId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
distance: number;
|
||||
}>> {
|
||||
if (!this.signalsCollection || !this.connected) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryText = `${symbol} ${action} ${reasoning}`;
|
||||
const queryEmbedding = this.generateSimpleEmbedding(queryText);
|
||||
|
||||
const results = await this.signalsCollection.query({
|
||||
queryEmbeddings: [queryEmbedding],
|
||||
nResults,
|
||||
where: { symbol },
|
||||
include: [IncludeEnum.Metadatas, IncludeEnum.Distances]
|
||||
});
|
||||
|
||||
if (!results.ids[0]) return [];
|
||||
|
||||
return results.ids[0].map((id, index) => ({
|
||||
signalId: id,
|
||||
metadata: results.metadatas?.[0]?.[index] || {},
|
||||
distance: results.distances?.[0]?.[index] || 0
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error finding similar signals:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signal statistics from vector store
|
||||
*/
|
||||
async getSignalStatistics(symbol?: string): Promise<{
|
||||
totalSignals: number;
|
||||
winRate: number;
|
||||
avgConfidence: number;
|
||||
avgPnL: number;
|
||||
}> {
|
||||
// This would require aggregation queries in ChromaDB
|
||||
// For now, return placeholder values
|
||||
return {
|
||||
totalSignals: 0,
|
||||
winRate: 0,
|
||||
avgConfidence: 0,
|
||||
avgPnL: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old entries (cleanup)
|
||||
*/
|
||||
async cleanupOldEntries(beforeDate: Date): Promise<void> {
|
||||
if (!this.connected) return;
|
||||
|
||||
try {
|
||||
// ChromaDB doesn't have a direct delete by date,
|
||||
// would need to query and delete by IDs
|
||||
// This is a placeholder for cleanup logic
|
||||
} catch (error) {
|
||||
console.error('Error during cleanup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual news for signal generation
|
||||
*/
|
||||
async getContextualNews(
|
||||
symbol: string,
|
||||
timeframe: string,
|
||||
maxArticles: number = 10
|
||||
): Promise<NewsArticle[]> {
|
||||
// Search for relevant news
|
||||
const results = await this.searchSimilarNews(
|
||||
`${symbol} cryptocurrency trading`,
|
||||
maxArticles,
|
||||
{ symbol: { $contains: symbol } }
|
||||
);
|
||||
|
||||
return results.map(result => ({
|
||||
id: result.id,
|
||||
title: result.metadata.title as string || '',
|
||||
source: result.metadata.source as string || 'Unknown',
|
||||
sentiment: result.metadata.sentiment as number,
|
||||
importance: result.metadata.importance as number,
|
||||
category: result.metadata.category as string,
|
||||
publishedAt: new Date(result.metadata.publishedAt as string),
|
||||
fetchedAt: new Date(),
|
||||
processed: true,
|
||||
vectorId: result.id
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze text sentiment using stored knowledge
|
||||
*/
|
||||
async analyzeSentimentWithContext(text: string): Promise<{
|
||||
sentiment: number;
|
||||
label: SentimentLabel;
|
||||
relevantArticles: NewsArticle[];
|
||||
}> {
|
||||
// Search for similar articles
|
||||
const similar = await this.searchSimilarNews(text, 5);
|
||||
|
||||
// Calculate aggregate sentiment from similar articles
|
||||
let totalSentiment = 0;
|
||||
const relevantArticles: NewsArticle[] = [];
|
||||
|
||||
similar.forEach(result => {
|
||||
const sentiment = result.metadata.sentiment as number || 0;
|
||||
totalSentiment += sentiment;
|
||||
relevantArticles.push({
|
||||
id: result.id,
|
||||
title: result.metadata.title as string || '',
|
||||
source: result.metadata.source as string || 'Unknown',
|
||||
sentiment,
|
||||
publishedAt: new Date(result.metadata.publishedAt as string),
|
||||
fetchedAt: new Date(),
|
||||
processed: true
|
||||
});
|
||||
});
|
||||
|
||||
const avgSentiment = similar.length > 0 ? totalSentiment / similar.length : 0;
|
||||
|
||||
let label = SentimentLabel.NEUTRAL;
|
||||
if (avgSentiment >= 0.3) label = SentimentLabel.BULLISH;
|
||||
else if (avgSentiment >= 0.6) label = SentimentLabel.VERY_BULLISH;
|
||||
else if (avgSentiment <= -0.3) label = SentimentLabel.BEARISH;
|
||||
else if (avgSentiment <= -0.6) label = SentimentLabel.VERY_BEARISH;
|
||||
|
||||
return {
|
||||
sentiment: avgSentiment,
|
||||
label,
|
||||
relevantArticles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const vectorStore = new VectorStore();
|
||||
Reference in New Issue
Block a user