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:
Mantle AI Trader
2026-06-06 06:02:07 +00:00
Unverified
parent 6664758a6d
commit b1da4ee01d
100 changed files with 16113 additions and 0 deletions

13
src/lib/db.ts Normal file
View 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

View 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();

View 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);
}

View 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;
}

View 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();

View 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();

View 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
View 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))
}

View 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();