← Назад
'use strict'; const trading = require('./trading'); const config = require('../config'); const logger = require('../utils/logger'); const axios = require('axios'); const { logOpenTrade, logTrade } = require('./tradeLogger'); // ─── Push notification helper ────────────────────────── async function sendTradePush(title, body) { try { const { broadcastPushNotification } = require('../routes/notifications'); await broadcastPushNotification({ title, body, icon: '/pwa-192x192.png', data: { url: '/?tab=trading' }, }); } catch (err) { logger.error(`[TRADE_MGR] Push error: ${err.message}`); } } // ─── In-memory active trades ──────────────────────────── // { symbol, buyOrderId, qty, entryPrice, tpPrice, tpOrderId, status } // status: PENDING_FILL | FILLED_PLACING_TP | TP_PLACED | CLOSED | ERROR const activeTrades = new Map(); const MAX_POSITION_COST = 6; // $6 max per position (allows $5 + rounding) // Cache exchange info for price limits (refreshed on startup) let exchangeFilters = {}; async function loadExchangeFilters() { try { const resp = await axios.get('https://eapi.binance.com/eapi/v1/exchangeInfo'); for (const sym of (resp.data.optionSymbols || [])) { const priceFilter = sym.filters?.find(f => f.filterType === 'PRICE_FILTER'); if (priceFilter) { exchangeFilters[sym.symbol] = { maxPrice: parseFloat(priceFilter.maxPrice), minPrice: parseFloat(priceFilter.minPrice), tickSize: parseFloat(priceFilter.tickSize), }; } } logger.info(`[TRADE_MGR] Loaded filters for ${Object.keys(exchangeFilters).length} symbols`); } catch (err) { logger.error(`[TRADE_MGR] Failed to load exchange filters: ${err.message}`); } } // Load on startup loadExchangeFilters(); // Refresh every hour setInterval(loadExchangeFilters, 3600000); function clampPrice(symbol, price) { const f = exchangeFilters[symbol]; if (!f) return price; // Clamp to maxPrice let p = Math.min(price, f.maxPrice); // Round to tick size p = Math.round(p / f.tickSize) * f.tickSize; return parseFloat(p.toFixed(4)); } // ─── Open trade with auto-TP ──────────────────────────── // source: 'manual' (UI/API) or 'auto_gamma' (auto-trader) async function openTradeWithTP({ symbol, quantity, price, tpPct, source = 'manual' }) { // Validate $5 cap const cost = price * quantity; if (cost > MAX_POSITION_COST) { throw new Error(`Position cost $${cost.toFixed(2)} exceeds $${MAX_POSITION_COST} limit. Reduce qty or price.`); } // Place BUY order const buyResult = await trading.placeOrder({ symbol, side: 'BUY', quantity, price }); // Calculate TP price (clamped to exchange maxPrice) const rawTp = price * (1 + tpPct / 100); const tpPrice = clampPrice(symbol, rawTp); // Store trade for monitoring const trade = { symbol, buyOrderId: buyResult.orderId, qty: parseFloat(quantity), entryPrice: price, tpPct, tpPrice, tpOrderId: null, status: 'PENDING_FILL', source, createdAt: new Date().toISOString(), }; activeTrades.set(buyResult.orderId, trade); const isAuto = source === 'auto_gamma'; const tag = isAuto ? 'πŸ€– AUTO' : 'πŸ‘€ MANUAL'; logger.info(`[TRADE_MGR] ${tag} Trade opened: ${symbol} qty=${quantity} entry=$${price} TP=$${tpPrice} (${tpPct}%)`); // Push notification on entry (auto trades only β€” manual user already knows) if (isAuto) { const parts = symbol.split('-'); const asset = parts[0]; const strike = parts[2]; const type = parts[3] === 'C' ? 'CALL' : 'PUT'; const cost = (price * quantity).toFixed(2); sendTradePush( `πŸ€– AUTO: ${asset} ${type} $${strike}`, `Π’Ρ…ΠΎΠ΄ $${price} Γ— ${quantity} = $${cost}. TP $${tpPrice} (+${tpPct}%)` ); } // Try immediate check β€” maybe already filled setTimeout(() => checkTrade(buyResult.orderId).catch(() => {}), 2000); return { ...buyResult, tpPrice, tpPct }; } // ─── Check single trade β€” fill β†’ place TP ─────────────── async function checkTrade(buyOrderId) { const trade = activeTrades.get(buyOrderId); if (!trade || trade.status === 'TP_PLACED' || trade.status === 'CLOSED') return; try { const order = await trading.queryOrder(trade.symbol, buyOrderId); if (order.status === 'FILLED' && trade.status === 'PENDING_FILL') { trade.status = 'FILLED_PLACING_TP'; // Use actual fill price if available const fillPrice = parseFloat(order.avgPrice) || trade.entryPrice; trade.entryPrice = fillPrice; trade.tpPrice = clampPrice(trade.symbol, fillPrice * (1 + trade.tpPct / 100)); const filledQty = parseFloat(order.executedQty) || trade.qty; logger.info(`[TRADE_MGR] BUY filled: ${trade.symbol} @ $${fillPrice}. Placing TP SELL @ $${trade.tpPrice}`); // Log open trade to journal try { await logOpenTrade({ tradeId: `tm-${buyOrderId}`, symbol: trade.symbol, side: 'BUY', quantity: filledQty, entryPrice: fillPrice, entryTime: new Date().toISOString(), source: trade.source || 'manual', }); } catch (logErr) { logger.error(`[TRADE_MGR] Journal log error: ${logErr.message}`); } try { const tpResult = await trading.placeOrder({ symbol: trade.symbol, side: 'SELL', quantity: filledQty, price: trade.tpPrice, reduceOnly: true, }); trade.tpOrderId = tpResult.orderId; trade.status = 'TP_PLACED'; logger.info(`[TRADE_MGR] TP SELL placed: orderId=${tpResult.orderId} @ $${trade.tpPrice}`); } catch (err) { trade.status = 'ERROR'; logger.error(`[TRADE_MGR] Failed to place TP: ${err.response?.data?.msg || err.message}`); } } else if (order.status === 'CANCELED' || order.status === 'EXPIRED' || order.status === 'REJECTED') { trade.status = 'CLOSED'; logger.info(`[TRADE_MGR] BUY order ${order.status}: ${trade.symbol}`); } } catch (err) { logger.error(`[TRADE_MGR] checkTrade error: ${err.response?.data?.msg || err.message}`); } } // ─── Check TP fills ───────────────────────────────────── async function checkTPFills() { for (const [buyOrderId, trade] of activeTrades) { if (trade.status !== 'TP_PLACED' || !trade.tpOrderId) continue; try { const order = await trading.queryOrder(trade.symbol, trade.tpOrderId); if (order.status === 'FILLED') { const sellPrice = parseFloat(order.avgPrice) || trade.tpPrice; const pnl = (sellPrice - trade.entryPrice) * trade.qty; const roi = ((sellPrice / trade.entryPrice) - 1) * 100; trade.status = 'CLOSED'; trade.exitReason = 'TP'; const tag = trade.source === 'auto_gamma' ? 'πŸ€–' : 'πŸ‘€'; logger.info(`[TRADE_MGR] ${tag} βœ… TP HIT! ${trade.symbol} entry=$${trade.entryPrice} exit=$${sellPrice} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(0)}%`); // Log closed trade to journal try { await logTrade({ tradeId: `tm-${buyOrderId}`, symbol: trade.symbol, side: 'BUY', quantity: trade.qty, entryPrice: trade.entryPrice, exitPrice: sellPrice, entryTime: trade.createdAt, exitTime: new Date().toISOString(), source: trade.source || 'manual', exitReason: 'TP', }); } catch (logErr) { logger.error(`[TRADE_MGR] Journal close error: ${logErr.message}`); } // Push notification β€” TP hit const parts = trade.symbol.split('-'); sendTradePush( `${tag} βœ… TP HIT +${roi.toFixed(0)}%!`, `${parts[0]} ${parts[3] === 'C' ? 'CALL' : 'PUT'} $${parts[2]}: $${trade.entryPrice} β†’ $${sellPrice.toFixed(4)}. PnL $${pnl.toFixed(2)}` ); } else if (order.status === 'CANCELED' || order.status === 'EXPIRED' || order.status === 'REJECTED') { trade.status = 'CLOSED'; trade.exitReason = order.status; const tag = trade.source === 'auto_gamma' ? 'πŸ€–' : 'πŸ‘€'; logger.warn(`[TRADE_MGR] ${tag} TP order ${order.status}: ${trade.symbol}`); // Push β€” TP cancelled/expired (likely option expired) if (trade.source === 'auto_gamma') { const parts = trade.symbol.split('-'); sendTradePush( `${tag} ⏰ Expired: ${parts[0]} ${parts[3] === 'C' ? 'CALL' : 'PUT'} $${parts[2]}`, `TP order ${order.status}. Entry $${trade.entryPrice}, qty ${trade.qty}` ); } } } catch (err) { // ignore query errors silently } } } // ─── Monitor cycle (called from scheduler every 30s) ──── async function monitorTrades() { if (!config.trading.enabled) return; if (activeTrades.size === 0) return; // Check pending BUY fills β†’ place TP for (const [buyOrderId, trade] of activeTrades) { if (trade.status === 'PENDING_FILL') { await checkTrade(buyOrderId); } } // Check TP fills await checkTPFills(); // Clean up closed trades older than 1 hour const now = Date.now(); for (const [id, trade] of activeTrades) { if (trade.status === 'CLOSED' && now - new Date(trade.createdAt).getTime() > 3600000) { activeTrades.delete(id); } } } // ─── Get all trades (for API) ─────────────────────────── function getTrades() { return Array.from(activeTrades.values()); } // Get exchange maxPrice for a symbol (used by scheduler for TP room check) function getMaxPrice(symbol) { const f = exchangeFilters[symbol]; return f ? f.maxPrice : null; } module.exports = { openTradeWithTP, monitorTrades, getTrades, clampPrice, getMaxPrice, MAX_POSITION_COST, };