bybit-service.ts•35.8 kB
import crypto from 'crypto';
import fetch from 'node-fetch';
import dotenv from 'dotenv';
import {
BybitConfig,
BybitResponse,
TickerData,
OrderbookData,
KlineData,
WalletBalance,
Position,
OrderRequest,
OrderResponse,
Order,
ApiKeyInfo,
InstrumentInfo
} from './types.js';
dotenv.config();
export class BybitService {
private config: BybitConfig;
private baseUrl: string;
constructor() {
this.config = {
accessKey: process.env.ACCESS_KEY || '',
secretKey: process.env.SECRET_KEY || '',
demo: process.env.DEMO?.toLowerCase() === 'true',
testnet: process.env.TESTNET?.toLowerCase() === 'true'
};
// Determine base URL based on environment
if (this.config.demo) {
this.baseUrl = 'https://api-demo.bybit.com';
console.log('Using Demo environment: https://api-demo.bybit.com');
} else if (this.config.testnet) {
this.baseUrl = 'https://api-testnet.bybit.com';
console.log('Using Testnet environment: https://api-testnet.bybit.com');
} else {
this.baseUrl = 'https://api.bybit.com';
console.log('Using Production environment: https://api.bybit.com');
}
console.log(`Initialized Bybit Service - Demo: ${this.config.demo}, Testnet: ${this.config.testnet}`);
}
/**
* Make authenticated request to Bybit API
* This uses the EXACT same signature generation as your working Cloud Functions code
*/
private async makeBybitRequest<T = any>(
endpoint: string,
method: 'GET' | 'POST' = 'GET',
params: any = {},
maxRetries: number = 3
): Promise<BybitResponse<T> | { error: string; retCode?: number }> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const timestamp = Date.now().toString();
// Create signature - EXACT same logic as your Cloud Functions
let queryString = '';
if (method === 'GET') {
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
queryString = sortedParams;
} else {
// For POST requests - exact Node.js JSON.stringify behavior
queryString = params && Object.keys(params).length > 0
? JSON.stringify(params)
: '';
}
// Signature payload - exact same format as your Cloud Functions
const signaturePayload = timestamp + this.config.accessKey + queryString;
const signature = crypto
.createHmac('sha256', this.config.secretKey)
.update(signaturePayload)
.digest('hex');
const headers = {
'X-BAPI-API-KEY': this.config.accessKey,
'X-BAPI-SIGN': signature,
'X-BAPI-TIMESTAMP': timestamp,
'Content-Type': 'application/json',
'User-Agent': 'BybitMCP-Node/1.0'
};
const url = method === 'GET' && queryString
? `${this.baseUrl}${endpoint}?${queryString}`
: `${this.baseUrl}${endpoint}`;
const requestOptions: any = {
method,
headers,
timeout: 10000
};
if (method === 'POST' && params && Object.keys(params).length > 0) {
requestOptions.body = JSON.stringify(params);
}
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as BybitResponse<T>;
if (data.retCode === 0) {
return data;
} else {
console.error(`Bybit API error: ${data.retMsg} (Code: ${data.retCode})`);
return { error: data.retMsg, retCode: data.retCode };
}
} catch (error: any) {
if (attempt === maxRetries) {
console.error(`Request failed after ${maxRetries} attempts:`, error.message);
return { error: error.message };
} else {
console.warn(`Request attempt ${attempt} failed, retrying:`, error.message);
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
return { error: 'Max retries exceeded' };
}
// Market Data Methods
async getTickers(category: string, symbol: string): Promise<BybitResponse<{ list: TickerData[] }> | { error: string }> {
return this.makeBybitRequest('/v5/market/tickers', 'GET', { category, symbol });
}
async getOrderbook(category: string, symbol: string, limit: number = 50): Promise<BybitResponse<OrderbookData> | { error: string }> {
return this.makeBybitRequest('/v5/market/orderbook', 'GET', { category, symbol, limit });
}
async getKline(
category: string,
symbol: string,
interval: string,
start?: number,
end?: number,
limit: number = 200
): Promise<BybitResponse<KlineData> | { error: string }> {
const params: any = { category, symbol, interval, limit };
if (start) params.start = start;
if (end) params.end = end;
return this.makeBybitRequest('/v5/market/kline', 'GET', params);
}
// Account Methods
async getWalletBalance(accountType: string, coin?: string): Promise<BybitResponse<{ list: WalletBalance[] }> | { error: string }> {
const params: any = { accountType };
if (coin) params.coin = coin;
return this.makeBybitRequest('/v5/account/wallet-balance', 'GET', params);
}
async getPositions(category: string, symbol?: string): Promise<BybitResponse<{ list: Position[] }> | { error: string }> {
const params: any = { category };
if (symbol) {
params.symbol = symbol;
} else {
// Add settleCoin for linear/inverse when no symbol specified
if (category === 'linear') {
params.settleCoin = 'USDT';
} else if (category === 'inverse') {
params.settleCoin = 'BTC';
}
}
return this.makeBybitRequest('/v5/position/list', 'GET', params);
}
async getApiKeyInformation(): Promise<BybitResponse<ApiKeyInfo> | { error: string }> {
return this.makeBybitRequest('/v5/user/query-api', 'GET', {});
}
// Advanced Trading Methods
async placeOrderWithTrailingStop(
category: string,
symbol: string,
side: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
qty: string,
price?: string,
trailingStop?: string,
activePrice?: string,
positionIdx?: string,
timeInForce?: string,
orderLinkId?: string
): Promise<BybitResponse<OrderResponse> | { error: string }> {
// Auto-detect position mode for futures trading if positionIdx not provided
if (['linear', 'inverse'].includes(category) && !positionIdx) {
console.log('Auto-detecting position mode for futures trading...');
// Check current positions to determine position mode
const currentPositions = await this.getPositions(category, symbol);
let detectedPositionIdx = '0'; // Default to one-way mode
if ('result' in currentPositions && currentPositions.result.list.length > 0) {
// Check if we have separate long/short positions (hedge mode)
const positions = currentPositions.result.list;
const hasLongPosition = positions.some(p => p.side === 'Buy');
const hasShortPosition = positions.some(p => p.side === 'Sell');
if (hasLongPosition || hasShortPosition) {
// Hedge mode detected - use appropriate position index
detectedPositionIdx = side === 'Buy' ? '1' : '2';
console.log(`Hedge mode detected, using positionIdx: ${detectedPositionIdx}`);
} else {
console.log('One-way mode detected, using positionIdx: 0');
}
} else {
console.log('No existing positions, defaulting to one-way mode (positionIdx: 0)');
}
positionIdx = detectedPositionIdx;
}
// Validate positionIdx for futures trading (after auto-detection)
if (['linear', 'inverse'].includes(category)) {
if (!positionIdx || !['0', '1', '2'].includes(positionIdx)) {
return { error: 'Invalid positionIdx. Use 0 for one-way mode, 1 for long position, or 2 for short position in hedge mode' };
}
}
// Build order request with trailing stop
const orderRequest: any = {
category,
symbol,
side,
orderType,
qty,
timeInForce: timeInForce || (orderType === 'Market' ? 'IOC' : 'GTC'),
orderFilter: 'Order',
isLeverage: 0
};
// Add price for limit orders
if (orderType === 'Limit' && price) {
orderRequest.price = price;
}
// Add trailing stop parameters
if (trailingStop) {
orderRequest.trailingStop = trailingStop;
// Add active price if provided (price at which trailing stop activates)
if (activePrice) {
orderRequest.activePrice = activePrice;
}
}
// Add position index for futures
if (['linear', 'inverse'].includes(category)) {
orderRequest.positionIdx = positionIdx;
}
// Add order link ID if provided
if (orderLinkId) {
orderRequest.orderLinkId = orderLinkId;
}
return this.makeBybitRequest('/v5/order/create', 'POST', orderRequest);
}
// Trading Methods
async placeOrder(orderRequest: OrderRequest): Promise<BybitResponse<OrderResponse> | { error: string }> {
// Auto-detect position mode for futures trading if positionIdx not provided
if (['linear', 'inverse'].includes(orderRequest.category) && !orderRequest.positionIdx) {
console.log('Auto-detecting position mode for futures trading...');
// Check current positions to determine position mode
const currentPositions = await this.getPositions(orderRequest.category, orderRequest.symbol);
let detectedPositionIdx = '0'; // Default to one-way mode
if ('result' in currentPositions && currentPositions.result.list.length > 0) {
// Check if we have separate long/short positions (hedge mode)
const positions = currentPositions.result.list;
const hasLongPosition = positions.some(p => p.side === 'Buy');
const hasShortPosition = positions.some(p => p.side === 'Sell');
if (hasLongPosition || hasShortPosition) {
// Hedge mode detected - use appropriate position index
detectedPositionIdx = orderRequest.side === 'Buy' ? '1' : '2';
console.log(`Hedge mode detected, using positionIdx: ${detectedPositionIdx}`);
} else {
console.log('One-way mode detected, using positionIdx: 0');
}
} else {
console.log('No existing positions, defaulting to one-way mode (positionIdx: 0)');
}
// Set the detected position index
orderRequest.positionIdx = detectedPositionIdx;
}
// Validate positionIdx for futures trading (after auto-detection)
if (['linear', 'inverse'].includes(orderRequest.category)) {
if (!orderRequest.positionIdx || !['0', '1', '2'].includes(orderRequest.positionIdx)) {
return { error: 'Invalid positionIdx. Use 0 for one-way mode, 1 for long position, or 2 for short position in hedge mode' };
}
}
// Set defaults
const requestData = {
...orderRequest,
timeInForce: orderRequest.timeInForce || (orderRequest.orderType === 'Market' ? 'IOC' : 'GTC'),
orderFilter: orderRequest.orderFilter || 'Order',
isLeverage: orderRequest.isLeverage || 0
};
// Remove positionIdx for spot trading
if (orderRequest.category === 'spot') {
delete requestData.positionIdx;
}
return this.makeBybitRequest('/v5/order/create', 'POST', requestData);
}
async cancelOrder(
category: string,
symbol: string,
orderId?: string,
orderLinkId?: string,
orderFilter?: string
): Promise<BybitResponse<any> | { error: string }> {
const params: any = { category, symbol };
if (orderId) params.orderId = orderId;
if (orderLinkId) params.orderLinkId = orderLinkId;
if (orderFilter) params.orderFilter = orderFilter;
return this.makeBybitRequest('/v5/order/cancel', 'POST', params);
}
async getOrderHistory(
category: string,
symbol?: string,
orderId?: string,
orderLinkId?: string,
orderFilter?: string,
orderStatus?: string,
startTime?: number,
endTime?: number,
limit: number = 50
): Promise<BybitResponse<{ list: Order[] }> | { error: string }> {
const params: any = { category, limit };
if (symbol) params.symbol = symbol;
if (orderId) params.orderId = orderId;
if (orderLinkId) params.orderLinkId = orderLinkId;
if (orderFilter) params.orderFilter = orderFilter;
if (orderStatus) params.orderStatus = orderStatus;
if (startTime) params.startTime = startTime;
if (endTime) params.endTime = endTime;
return this.makeBybitRequest('/v5/order/history', 'GET', params);
}
async getOpenOrders(
category: string,
symbol?: string,
orderId?: string,
orderLinkId?: string,
orderFilter?: string,
limit: number = 50
): Promise<BybitResponse<{ list: Order[] }> | { error: string }> {
const params: any = { category, limit };
if (symbol) params.symbol = symbol;
if (orderId) params.orderId = orderId;
if (orderLinkId) params.orderLinkId = orderLinkId;
if (orderFilter) params.orderFilter = orderFilter;
return this.makeBybitRequest('/v5/order/realtime', 'GET', params);
}
// Position Management Methods
async setLeverage(
category: string,
symbol: string,
buyLeverage: string,
sellLeverage: string
): Promise<BybitResponse<any> | { error: string }> {
return this.makeBybitRequest('/v5/position/set-leverage', 'POST', {
category,
symbol,
buyLeverage,
sellLeverage
});
}
async setTradingStop(
category: string,
symbol: string,
takeProfit?: string,
stopLoss?: string,
trailingStop?: string,
positionIdx?: number
): Promise<BybitResponse<any> | { error: string }> {
const params: any = { category, symbol };
if (takeProfit) params.takeProfit = takeProfit;
if (stopLoss) params.stopLoss = stopLoss;
if (trailingStop) params.trailingStop = trailingStop;
if (positionIdx !== undefined) params.positionIdx = positionIdx;
return this.makeBybitRequest('/v5/position/trading-stop', 'POST', params);
}
async setMarginMode(
category: string,
symbol: string,
tradeMode: number,
buyLeverage: string,
sellLeverage: string
): Promise<BybitResponse<any> | { error: string }> {
return this.makeBybitRequest('/v5/account/set-margin-mode', 'POST', {
category,
symbol,
tradeMode,
buyLeverage,
sellLeverage
});
}
// Utility Methods
async getInstrumentsInfo(
category: string,
symbol: string,
status?: string,
baseCoin?: string
): Promise<BybitResponse<{ list: InstrumentInfo[] }> | { error: string }> {
const params: any = { category, symbol };
if (status) params.status = status;
if (baseCoin) params.baseCoin = baseCoin;
return this.makeBybitRequest('/v5/market/instruments-info', 'GET', params);
}
// Position Mode Detection
async detectPositionMode(
category: string,
symbol?: string
): Promise<{
positionMode: 'one-way' | 'hedge';
recommendedPositionIdx: string;
explanation: string;
currentPositions: any[];
}> {
try {
// Get current positions
const currentPositions = await this.getPositions(category, symbol);
if ('error' in currentPositions) {
return {
positionMode: 'one-way',
recommendedPositionIdx: '0',
explanation: `Could not check positions: ${currentPositions.error}. Defaulting to one-way mode.`,
currentPositions: []
};
}
const positions = currentPositions.result.list;
if (positions.length === 0) {
return {
positionMode: 'one-way',
recommendedPositionIdx: '0',
explanation: 'No existing positions found. Defaulting to one-way mode (positionIdx: 0).',
currentPositions: []
};
}
// Check if we have separate long/short positions (hedge mode)
const hasLongPosition = positions.some(p => p.side === 'Buy' && parseFloat(p.size) > 0);
const hasShortPosition = positions.some(p => p.side === 'Sell' && parseFloat(p.size) > 0);
if (hasLongPosition && hasShortPosition) {
return {
positionMode: 'hedge',
recommendedPositionIdx: '1', // Default to long for new orders
explanation: 'Hedge mode detected (both long and short positions exist). Use positionIdx: 1 for long, 2 for short.',
currentPositions: positions
};
} else if (hasLongPosition || hasShortPosition) {
// Check if positions have positionIdx > 0 (indicating hedge mode setup)
const hasHedgePositions = positions.some(p => p.positionIdx > 0);
if (hasHedgePositions) {
return {
positionMode: 'hedge',
recommendedPositionIdx: '1',
explanation: 'Hedge mode detected (positions have positionIdx > 0). Use positionIdx: 1 for long, 2 for short.',
currentPositions: positions
};
} else {
return {
positionMode: 'one-way',
recommendedPositionIdx: '0',
explanation: 'One-way mode detected (single position type with positionIdx: 0).',
currentPositions: positions
};
}
} else {
return {
positionMode: 'one-way',
recommendedPositionIdx: '0',
explanation: 'One-way mode detected (no active positions, but account configured for one-way).',
currentPositions: positions
};
}
} catch (error: any) {
return {
positionMode: 'one-way',
recommendedPositionIdx: '0',
explanation: `Error detecting position mode: ${error.message}. Defaulting to one-way mode.`,
currentPositions: []
};
}
}
// Trailing Stop Loss Methods
async calculateTrailingStop(
category: string,
symbol: string,
entryPrice: number,
currentPrice: number,
side: 'Buy' | 'Sell',
initialStopLoss: number,
trailingDistance: number,
breakevenTrigger?: number,
profitProtectionTrigger?: number
): Promise<{
newStopLoss: number;
trailingActivated: boolean;
breakevenProtection: boolean;
profitProtected: number;
unrealizedPnL: number;
unrealizedPnLPercentage: number;
recommendations: {
shouldUpdateStop: boolean;
newStopPrice: number;
protectionLevel: 'none' | 'breakeven' | 'profit';
reasoning: string;
};
calculations: {
priceMovement: number;
priceMovementPercentage: number;
stopLossMovement: number;
maxDrawdownProtection: number;
profitLocked: number;
};
warnings: string[];
error?: string;
}> {
try {
const warnings: string[] = [];
const isLong = side === 'Buy';
// Validate inputs
if (trailingDistance <= 0) {
return {
newStopLoss: initialStopLoss,
trailingActivated: false,
breakevenProtection: false,
profitProtected: 0,
unrealizedPnL: 0,
unrealizedPnLPercentage: 0,
recommendations: {
shouldUpdateStop: false,
newStopPrice: initialStopLoss,
protectionLevel: 'none',
reasoning: 'Invalid trailing distance'
},
calculations: {
priceMovement: 0,
priceMovementPercentage: 0,
stopLossMovement: 0,
maxDrawdownProtection: 0,
profitLocked: 0
},
warnings: ['Trailing distance must be greater than 0'],
error: 'Invalid trailing distance'
};
}
// Calculate price movements
const priceMovement = isLong ? currentPrice - entryPrice : entryPrice - currentPrice;
const priceMovementPercentage = (priceMovement / entryPrice) * 100;
// Calculate unrealized PnL
const unrealizedPnL = priceMovement;
const unrealizedPnLPercentage = priceMovementPercentage;
// Set default triggers if not provided
const breakevenTriggerDistance = breakevenTrigger || (Math.abs(entryPrice - initialStopLoss) * 1.5);
const profitProtectionTriggerDistance = profitProtectionTrigger || (Math.abs(entryPrice - initialStopLoss) * 2.0);
let newStopLoss = initialStopLoss;
let trailingActivated = false;
let breakevenProtection = false;
let profitProtected = 0;
let protectionLevel: 'none' | 'breakeven' | 'profit' = 'none';
let reasoning = 'No trailing conditions met';
// Check if price has moved favorably enough to activate trailing
const favorableMovement = isLong ? currentPrice > entryPrice : currentPrice < entryPrice;
if (favorableMovement) {
// Calculate potential new stop loss based on trailing distance
const potentialNewStop = isLong
? currentPrice - trailingDistance
: currentPrice + trailingDistance;
// Only move stop loss if it's better than current stop
const shouldMoveStop = isLong
? potentialNewStop > initialStopLoss
: potentialNewStop < initialStopLoss;
if (shouldMoveStop) {
newStopLoss = potentialNewStop;
trailingActivated = true;
// Check for breakeven protection
const breakevenReached = isLong
? currentPrice >= entryPrice + breakevenTriggerDistance
: currentPrice <= entryPrice - breakevenTriggerDistance;
if (breakevenReached) {
const breakevenStop = entryPrice; // Move stop to breakeven
if (isLong ? breakevenStop > newStopLoss : breakevenStop < newStopLoss) {
newStopLoss = breakevenStop;
breakevenProtection = true;
protectionLevel = 'breakeven';
reasoning = 'Breakeven protection activated - stop moved to entry price';
}
}
// Check for profit protection
const profitProtectionReached = isLong
? currentPrice >= entryPrice + profitProtectionTriggerDistance
: currentPrice <= entryPrice - profitProtectionTriggerDistance;
if (profitProtectionReached) {
const profitProtectionStop = isLong
? entryPrice + (profitProtectionTriggerDistance * 0.5) // Lock in 50% of the trigger distance as profit
: entryPrice - (profitProtectionTriggerDistance * 0.5);
if (isLong ? profitProtectionStop > newStopLoss : profitProtectionStop < newStopLoss) {
newStopLoss = profitProtectionStop;
profitProtected = Math.abs(profitProtectionStop - entryPrice);
protectionLevel = 'profit';
reasoning = `Profit protection activated - locking in ${profitProtected.toFixed(2)} profit`;
}
}
if (protectionLevel === 'none') {
reasoning = `Trailing stop activated - following price with ${trailingDistance} distance`;
}
} else {
reasoning = 'Price moved favorably but not enough to improve stop loss';
}
} else {
reasoning = 'Price has not moved favorably - maintaining original stop loss';
}
// Calculate additional metrics
const stopLossMovement = Math.abs(newStopLoss - initialStopLoss);
const maxDrawdownProtection = Math.abs(currentPrice - newStopLoss);
const profitLocked = Math.max(0, isLong ? newStopLoss - entryPrice : entryPrice - newStopLoss);
// Add warnings for risk management
if (unrealizedPnLPercentage < -5) {
warnings.push('Position is down more than 5% - consider reviewing strategy');
}
if (trailingDistance > Math.abs(entryPrice - initialStopLoss) * 2) {
warnings.push('Trailing distance is very large compared to initial risk - may give back too much profit');
}
if (Math.abs(unrealizedPnLPercentage) > 20) {
warnings.push('Large unrealized PnL - consider taking partial profits');
}
// Get instrument info for price formatting
const instrumentInfo = await this.getInstrumentsInfo(category, symbol);
if ('result' in instrumentInfo) {
const priceFilter = instrumentInfo.result.list[0].priceFilter;
const tickSize = parseFloat(priceFilter.tickSize);
const decimalPlaces = tickSize.toString().split('.')[1]?.length || 0;
// Round new stop loss to valid tick size
newStopLoss = Math.round(newStopLoss / tickSize) * tickSize;
newStopLoss = parseFloat(newStopLoss.toFixed(decimalPlaces));
}
return {
newStopLoss,
trailingActivated,
breakevenProtection,
profitProtected,
unrealizedPnL,
unrealizedPnLPercentage,
recommendations: {
shouldUpdateStop: newStopLoss !== initialStopLoss,
newStopPrice: newStopLoss,
protectionLevel,
reasoning
},
calculations: {
priceMovement,
priceMovementPercentage,
stopLossMovement,
maxDrawdownProtection,
profitLocked
},
warnings
};
} catch (error: any) {
return {
newStopLoss: initialStopLoss,
trailingActivated: false,
breakevenProtection: false,
profitProtected: 0,
unrealizedPnL: 0,
unrealizedPnLPercentage: 0,
recommendations: {
shouldUpdateStop: false,
newStopPrice: initialStopLoss,
protectionLevel: 'none',
reasoning: 'Error calculating trailing stop'
},
calculations: {
priceMovement: 0,
priceMovementPercentage: 0,
stopLossMovement: 0,
maxDrawdownProtection: 0,
profitLocked: 0
},
warnings: [],
error: error.message
};
}
}
// Position Sizing Methods
async calculatePositionSize(
category: string,
symbol: string,
accountBalance: number,
riskPercentage: number,
stopLossPrice: number,
currentPrice?: number,
leverage?: number
): Promise<{
recommendedQty: string;
maxQty: string;
riskAmount: number;
positionValue: number;
stopLossDistance: number;
riskRewardRatio?: number;
takeProfitSuggestion?: number;
warnings: string[];
calculations: {
riskPerTrade: number;
priceDistance: number;
basePositionSize: number;
leveragedPositionSize: number;
marginRequired: number;
};
error?: string;
}> {
try {
// Get current price if not provided
let price = currentPrice;
if (!price) {
const ticker = await this.getTickers(category, symbol);
if ('error' in ticker) {
return {
recommendedQty: '0',
maxQty: '0',
riskAmount: 0,
positionValue: 0,
stopLossDistance: 0,
warnings: [],
calculations: {
riskPerTrade: 0,
priceDistance: 0,
basePositionSize: 0,
leveragedPositionSize: 0,
marginRequired: 0
},
error: `Failed to get price: ${ticker.error}`
};
}
price = parseFloat(ticker.result.list[0].lastPrice);
}
// Get instrument info for validation
const instrumentInfo = await this.getInstrumentsInfo(category, symbol);
if ('error' in instrumentInfo) {
return {
recommendedQty: '0',
maxQty: '0',
riskAmount: 0,
positionValue: 0,
stopLossDistance: 0,
warnings: [],
calculations: {
riskPerTrade: 0,
priceDistance: 0,
basePositionSize: 0,
leveragedPositionSize: 0,
marginRequired: 0
},
error: `Failed to get instrument info: ${instrumentInfo.error}`
};
}
const instrument = instrumentInfo.result.list[0];
const lotSizeFilter = instrument.lotSizeFilter;
const minOrderQty = parseFloat(lotSizeFilter.minOrderQty);
const maxOrderQty = parseFloat(lotSizeFilter.maxOrderQty);
const qtyStep = parseFloat(lotSizeFilter.qtyStep);
const warnings: string[] = [];
const usedLeverage = leverage || 1;
// Validate inputs
if (riskPercentage <= 0 || riskPercentage > 100) {
warnings.push('Risk percentage should be between 0.1% and 100%');
riskPercentage = Math.max(0.1, Math.min(100, riskPercentage));
}
if (riskPercentage > 5) {
warnings.push('Risk percentage > 5% is considered high risk');
}
// Calculate risk amount
const riskAmount = (accountBalance * riskPercentage) / 100;
// Calculate stop loss distance
const stopLossDistance = Math.abs(price - stopLossPrice);
const stopLossPercentage = (stopLossDistance / price) * 100;
if (stopLossDistance === 0) {
return {
recommendedQty: '0',
maxQty: '0',
riskAmount,
positionValue: 0,
stopLossDistance: 0,
warnings: ['Stop loss price cannot equal current price'],
calculations: {
riskPerTrade: riskAmount,
priceDistance: 0,
basePositionSize: 0,
leveragedPositionSize: 0,
marginRequired: 0
},
error: 'Invalid stop loss price'
};
}
// Calculate position size based on risk
// Risk Amount = Position Size * Stop Loss Distance
// Position Size = Risk Amount / Stop Loss Distance
const basePositionSize = riskAmount / stopLossDistance;
// With leverage, we can control a larger position with less margin
const leveragedPositionSize = basePositionSize * usedLeverage;
// But we need to ensure we don't exceed our margin capacity
const marginRequired = (leveragedPositionSize * price) / usedLeverage;
// Adjust if margin required exceeds available balance
let finalPositionSize = leveragedPositionSize;
if (marginRequired > accountBalance * 0.8) { // Keep 20% buffer
finalPositionSize = (accountBalance * 0.8 * usedLeverage) / price;
warnings.push('Position size reduced due to margin constraints');
}
// Round to valid quantity step
let recommendedQty = Math.floor(finalPositionSize / qtyStep) * qtyStep;
// Ensure minimum quantity
if (recommendedQty < minOrderQty) {
recommendedQty = minOrderQty;
warnings.push(`Adjusted to minimum quantity: ${minOrderQty}`);
}
// Ensure maximum quantity
if (recommendedQty > maxOrderQty) {
recommendedQty = maxOrderQty;
warnings.push(`Adjusted to maximum quantity: ${maxOrderQty}`);
}
// Format to correct decimal places
const decimalPlaces = qtyStep.toString().split('.')[1]?.length || 0;
const formattedQty = recommendedQty.toFixed(decimalPlaces);
const maxQty = maxOrderQty.toFixed(decimalPlaces);
// Calculate position value and risk-reward suggestions
const positionValue = recommendedQty * price;
const actualRiskAmount = recommendedQty * stopLossDistance;
// Suggest take profit at 2:1 risk-reward ratio
const suggestedTakeProfit = stopLossPrice > price
? price - (2 * stopLossDistance) // Short position
: price + (2 * stopLossDistance); // Long position
const riskRewardRatio = 2.0; // Default 2:1 ratio
// Add risk management warnings
if (stopLossPercentage > 10) {
warnings.push(`Stop loss distance is ${stopLossPercentage.toFixed(1)}% - consider tighter stop loss`);
}
if (actualRiskAmount > riskAmount * 1.1) {
warnings.push('Actual risk exceeds target risk due to position size constraints');
}
return {
recommendedQty: formattedQty,
maxQty,
riskAmount: actualRiskAmount,
positionValue,
stopLossDistance,
riskRewardRatio,
takeProfitSuggestion: suggestedTakeProfit,
warnings,
calculations: {
riskPerTrade: riskAmount,
priceDistance: stopLossDistance,
basePositionSize,
leveragedPositionSize: finalPositionSize,
marginRequired: (finalPositionSize * price) / usedLeverage
}
};
} catch (error: any) {
return {
recommendedQty: '0',
maxQty: '0',
riskAmount: 0,
positionValue: 0,
stopLossDistance: 0,
warnings: [],
calculations: {
riskPerTrade: 0,
priceDistance: 0,
basePositionSize: 0,
leveragedPositionSize: 0,
marginRequired: 0
},
error: error.message
};
}
}
// Order Validation Methods
async validateOrderQuantity(
category: string,
symbol: string,
targetAmount: number,
currentPrice?: number
): Promise<{
isValid: boolean;
validatedQty: string;
estimatedCost: string;
adjustments: string[];
error?: string;
}> {
try {
// Get current price if not provided
let price = currentPrice;
if (!price) {
const ticker = await this.getTickers(category, symbol);
if ('error' in ticker) {
return {
isValid: false,
validatedQty: '0',
estimatedCost: '0',
adjustments: [],
error: `Failed to get price: ${ticker.error}`
};
}
price = parseFloat(ticker.result.list[0].lastPrice);
}
// Get instrument info
const instrumentInfo = await this.getInstrumentsInfo(category, symbol);
if ('error' in instrumentInfo) {
return {
isValid: false,
validatedQty: '0',
estimatedCost: '0',
adjustments: [],
error: `Failed to get instrument info: ${instrumentInfo.error}`
};
}
const instrument = instrumentInfo.result.list[0];
const lotSizeFilter = instrument.lotSizeFilter;
const minOrderQty = parseFloat(lotSizeFilter.minOrderQty);
const maxOrderQty = parseFloat(lotSizeFilter.maxOrderQty);
const qtyStep = parseFloat(lotSizeFilter.qtyStep);
const adjustments: string[] = [];
let rawOrderQty = targetAmount / price;
// Round to the nearest valid quantity step
let orderQty = Math.round(rawOrderQty / qtyStep) * qtyStep;
// Ensure it meets minimum requirements
if (orderQty < minOrderQty) {
orderQty = minOrderQty;
adjustments.push(`Adjusted to minimum quantity: ${orderQty}`);
}
// Ensure it doesn't exceed maximum
if (orderQty > maxOrderQty) {
orderQty = maxOrderQty;
adjustments.push(`Adjusted to maximum quantity: ${orderQty}`);
}
// Format to the correct decimal places based on qtyStep
const decimalPlaces = qtyStep.toString().split('.')[1]?.length || 0;
const validatedQty = orderQty.toFixed(decimalPlaces);
const estimatedCost = (orderQty * price).toFixed(2);
return {
isValid: true,
validatedQty,
estimatedCost,
adjustments,
};
} catch (error: any) {
return {
isValid: false,
validatedQty: '0',
estimatedCost: '0',
adjustments: [],
error: error.message
};
}
}
}